launch-quickly
Patterns

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
presetreach for it when
defaulta balanced product with no strong opinion — the safe choice, not the free one
editorialthe writing is the product: content tools, knowledge bases, anything long-form
denseoperators live in it all day and a screen should hold twice as many rows
roundedapproachable and calm — onboarding-heavy, self-serve, non-technical users
playfulconsumer 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 needuse
a pagePageShell — pick width by content, see below
a title, description and actionsPageHeader (it renders the single <h1>)
a list of recordsDataTable + SortableHead + RowActions + TableFooterBar
controls above a listFilterBar + FilterSearch + FilterSelect + FilterChip
an empty listEmptyState — with an icon and a sentence
one recordDetailLayout + DetailFacts + DetailFact + DetailSection
a number on a dashboardStatCard (give it an href) inside StatCardGrid
a small chartChartCard + BarChart / CategoryBars
a titled panelSectionCard
anything destructiveConfirmDialog
a quick edit from inside a listDrawerForm
an enum a human readsStatusBadge 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 editor

Every 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:

  1. The one thing — the page title, the record's name, the number the dashboard is about. PageHeader and StatCard handle these.
  2. What you scan — row values, section titles, the status. Base size, font-medium where it is a label for something.
  3. What you glance at — timestamps, ids, helper text. text-muted-foreground text-sm or text-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-nums every number and date. Digits that do not line up are digits you cannot compare down a column.
  • Format through @/lib/format. Never toLocaleDateString() 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 StatusToneMap per 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:

  1. Is there a dead gutter, or content squeezed against an edge?
  2. Can I tell the most important thing on the page from three feet away?
  3. Do the statuses mean different things visually, or are they all grey?
  4. Does any date or number disagree with one on another screen?
  5. Is there placeholder copy left anywhere?
  6. Does the empty state explain itself?

On this page