Designing app UI
Which application component to use for which job, how to lay out a page, and the hierarchy, density and copy rules that make a screen look finished. Use when building or reviewing any signed-in screen.
Generated from
.claude/skills/designing-app-ui/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.
This skill exists because of a measurement. Three products were built on this
template by three independent agents, each from a real scaffold, and every
automated gate was green throughout — while the three came out looking like the
same unfinished app. src/components/ was byte-identical across all three.
An agent builds what the template makes easy. The template now makes the right thing easy; this is how to find it.
It was measured again after all of that shipped, on three fresh builds of the
same three specs. The floor rose a lot — real badge tones, sortable tables,
written copy, money right-aligned. And src/components/ was still
byte-identical across all three, because all three shipped the default theme.
Composing well inside one preset is most of this skill, and it is not all of it.
First: choose the preset
Do this before the first screen, because it changes every screen and it is the one decision that cannot be retrofitted cheaply.
lq theme list # what this project has, and which is active
lq theme set editorial # writes one attribute in src/app/layout.tsx| preset | reach for it when |
|---|---|
default | a balanced product with no strong opinion — the safe choice, not the free one |
editorial | the writing is the product: content tools, knowledge bases, anything long-form |
dense | operators live in it all day and a screen should hold twice as many rows |
rounded | approachable and calm — onboarding-heavy, self-serve, non-technical users |
playful | consumer or creative, where looking like enterprise software is the failure |
They differ in typography, density, elevation and border weight, not only in
hue, so the choice shows up on every table row and every button. Picking
default deliberately is fine. Picking it by not choosing is how six products
in a row came to look alike.
If you are given no latitude — a brand exists, a designer has decided — say so and move on. This is a decision to make once, not a thing to agonise over.
Then: start from the layer, not from a <div>
| you need | use |
|---|---|
| a page | PageShell — pick width by content, see below |
| a title, description and actions | PageHeader (it renders the single <h1>) |
| a list of records | DataTable + SortableHead + RowActions + TableFooterBar |
| controls above a list | FilterBar + FilterSearch + FilterSelect + FilterChip |
| an empty list | EmptyState — with an icon and a sentence |
| one record | DetailLayout + DetailFacts + DetailFact + DetailSection |
| a number on a dashboard | StatCard (give it an href) inside StatCardGrid |
| a small chart | ChartCard + BarChart / CategoryBars |
| a titled panel | SectionCard |
| anything destructive | ConfirmDialog |
| a quick edit from inside a list | DrawerForm |
| an enum a human reads | StatusBadge with the feature's StatusToneMap |
If you are writing rounded-lg border p-4, you are writing Card. If you are
writing <select>, you are writing Select. Both are lint errors, and both
were shipped three times.
Page width is a property of the content
<PageShell width="list"> // tables, dashboards — needs the room
<PageShell width="detail"> // one record, with a metadata rail
<PageShell width="form"> // a form or a settings page
<PageShell width="full"> // split views, boards, an editorEvery route in all three fleet products was max-w-3xl. At 1280px that is a
224px dead gutter beside a six-column table — and the same 768px is far too
wide for a form, where an input stretched full-width reads as a search box and
long lines of body text lose the eye between rows.
Hierarchy: three sizes, not two
The old template had text-2xl for the title and text-sm for everything
else, so on an invoice the amount had exactly as much weight as the memo.
A screen needs at least three levels:
- The one thing — the page title, the record's name, the number the
dashboard is about.
PageHeaderandStatCardhandle these. - What you scan — row values, section titles, the status. Base size,
font-mediumwhere it is a label for something. - What you glance at — timestamps, ids, helper text.
text-muted-foreground text-smortext-xs.
Never use opacity-* for level 3. It fades toward the background, lands
somewhere different in dark mode than every other muted label, and fades any
icon inside the element too.
The rules that decide whether a table looks professional
- Right-align and
tabular-numsevery number and date. Digits that do not line up are digits you cannot compare down a column. - Format through
@/lib/format. NevertoLocaleDateString()in a component — it uses the runtime's locale, so your laptop, CI and production can each render the same row differently. - Statuses get tones, not one grey pill. One
StatusToneMapper enum, in the feature, imported everywhere the value appears. - The row is not just a link. Add
RowActions, or every edit is open → find button → go back. - Show the result count. It is the only thing distinguishing "no results" from "results, but you are on page 7 of them".
- The skeleton is the same table. Same columns, same alignment, real header text — otherwise the page jumps when the data lands.
Empty states are two different problems
A filtered-empty list ("nothing matches those filters") and a genuinely empty account ("create your first") need different words, different icons and different actions. Offering "create your first" to someone who has twenty records and mistyped a search is the kind of small wrongness that makes software feel careless.
Both need a sentence, not a label. "No invoices yet." on its own reads as a bug; one line saying what an invoice is for and why you would make one reads as a product.
Copy is part of the design
The fleet shipped raw enum values (no_show, in_progress) into badges and
dropdowns, and left the template's placeholder dashboard text in production —
"Signed in as ff8e2843@example.test." Both are things a reader notices
immediately and neither is caught by any check.
- Humanise enums (
formatEnum, or an explicit map when it gets "SLA" wrong). - Give every page header a description. It is the cheapest usability win available and the first thing that gets skipped.
- Error and empty text says what happened and what to do next, in that order.
- Delete the template's example copy. If a string mentions "this starter", it is not yours yet.
Before you call a screen done
Look at it. Not at the tests — at a screenshot, at the width you expect people to use. The three products that failed this had green checks the entire time, because nothing automated measures whether a page looks finished.
Then ask:
- Is there a dead gutter, or content squeezed against an edge?
- Can I tell the most important thing on the page from three feet away?
- Do the statuses mean different things visually, or are they all grey?
- Does any date or number disagree with one on another screen?
- Is there placeholder copy left anywhere?
- Does the empty state explain itself?
UI and navigation
How the signed-in shell works — nav.ts as the single navigation declaration, permission-gated sidebar, breadcrumbs, theme presets, and how a new page becomes reachable. Use when adding a route, changing navigation, or theming.
Writing product copy
Voice, microcopy, empty states, error messages and enum labels — the words in the product, as opposed to the words on the landing page. Use when writing any user-visible string.