All documents

Buttons and controls

components/ui/button.tsx and its neighbours. These are shadcn new-york copies patched after generation — re-running the CLI reverts them.

Buttons: four variants, and four is the cap

VariantWhat it isUse for
default
The brand accent as a fill, ink --primary-foreground
The one primary action on a surface. Publish, Save, Sign in
quiet
1px border on the card surface, hover paints the accent tint
Everything else. Discard, Cancel, Back, secondary actions
ghost
No chrome until hover
Toolbar and icon actions inside a dense row
destructive
The destructive fill, hover adds an offset outline
The one irreversible action. Delete, Revoke

There is no link variant: a link is an <a>. Reaching for a fifth variant means the surface is asking for something the system does not have — raise it, do not add one.

One primary per surface. If a screen appears to need two, one of them is quiet. The admin screen is the model: Discard is quiet, Publish is default, and Discard only exists while there is something to discard.

The primary fill is the brand accent, so the same button is emerald in iq, sky in rei, orange in roof, with no code change.

Sizes

Measured; the full table is in 14-measurements.md.

SizeBoxNotes
default
36 px tall, 16 px padding
The standard button
sm
32 px tall, 12 px padding
Inside a page header's action row, inside a panel
xs
24 px tall, 8 px padding, 12 px icons
Inside a table row or a dense chip row
lg
40 px tall, 24 px padding
A page's single call to action, a marketing CTA
icon-xs / icon-sm / icon / icon-lg
32 / 36 / 40 / 44 px square
Icon-only

Padding tightens automatically when the button holds an icon (has-[>svg]:px-3). Everything under 44px paints an invisible 44px pointer target — never remove it to "tidy up" spacing.

States, and what each one looks like

  • Hover. default keeps its fill and gains a 2px ring; quiet and ghost paint --accent (the accent tint, not grey); destructive gains an offset outline. Hover never changes the label's colour.
  • Focus. focus-visible only: the border becomes --ring and a 3px ring is painted. Never remove it, never replace it with outline: none, and never rely on hover alone — the rail, the table and every menu are keyboard-driven.
  • Disabled. Three real changes — bg-muted, text-muted-foreground, border-border — plus cursor-not-allowed. Not opacity: a halved control is unreadable and still looks enabled at a glance.
  • Loading. The spinner goes inside the button, ahead of the label, and the button is disabled: {saving && <Loader2 className="size-3.5 animate-spin" />}. The label may change (PublishPublished) but the button must not resize.

Buttons that navigate

<Button asChild variant="quiet"><Link href="/coverage/docs">What covered means</Link></Button>

asChild renders the child as the button, so a link is a real <a> with the button's clothes. Never wrap a <Button> in an <a>.

Icon-only buttons take an aria-label, always.

Inputs

h-9 (36 px), rounded-md (6 px), 1px --input border, transparent fill (the host surface shows through — never a wash of the control's own border token), px-3, 3px focus ring. aria-invalid paints the destructive border and a full-opacity 3px destructive ring; invalid is a state you are meant to see.

Numeric fields are text-right font-mono tabular-nums and sized to their content (w-20 for a percentage), not stretched across the cell.

Selects

Same geometry as Input (36 px, sm is 32 px), transparent fill, hover paints the accent tint, the chevron is --muted-foreground at full opacity (never opacity-50 on top of a muted token — it lands under the 3:1 floor an affordance has to clear). Content is a portalled popover on the card surface at shadow-overlay, items 8px radius with the check on the right.

Use a Select for 2–7 stable options. Beyond that it is a search field or a command palette; below that, consider a ToggleGroup.

Toggles and segmented controls

A ToggleGroup always carries its edge. The group defaults to variant="outline" — a 1px --input hairline, the same edge an Input and a Select wear — and at the default spacing={0} the inner left borders drop and the ends round, so the items read as ONE control rather than as a row of words.

That default is load-bearing rather than cosmetic. A borderless group of two or three labels sitting in a header reads as a row of links, and nothing tells the reader that picking one un-picks the others until they click and watch something else go quiet. A group that genuinely wants no chrome passes variant="default" and says why in a comment; it is the rarer case and it is the one that has to argue.

Selected is an INK RULE, not a deeper fill. data-[state=on] paints the accent tint, recolours the border to --foreground and adds a 1px inset ring so the two read as one 2px edge. A fill alone was the original recipe and it failed: selected, hovered and resting all resolved within 0.15 of each other, so three states painted the same surface (19-elevation-and-depth.md for the inset family). Hover stays a fill, which is what keeps the two states from colliding.

ControlUse it forCap
Toggle
ONE thing on or off inside a toolbar. Bold, italic, pin
ToggleGroup
2–4 mutually exclusive VIEWS of one thing. Reading/Source, Week/Month/Year
4. Beyond that it is a Select
CountFilter
Filtering ONE list by an enumerated value, with how many are in each
The set may grow

CountFilter is the status selector at the top of a queue — a row of real buttons, the selected one taking the primary fill, each carrying its count in mono tabular figures. It is deliberately neither of the two above:

  • Not Tabs, because the count IS the information. A queue with 14 pending reads completely differently from one with 0, and that difference is the first thing anyone opening the screen wants — before they have chosen anything. A tab strip shows one selected label and hides the distribution behind three clicks.
  • Not a ToggleGroup, because a segmented control is for a fixed set of peer views. These are filters, the set grows with the domain, and joined segments with figures in them turn into a ribbon of digits.

countsFor(rows, values, of, labels) builds the options — including All — from the rows themselves. Use it. A screen that filters on one predicate and counts with another shows "Pending 14" over a list of eleven, and nothing on the screen says which of the two numbers is the lie. Empty cuts are still listed: a 0 says the state exists and nothing is in it, which is the answer to "where did the dismissed ones go".

CountFilterOption is { value, label, count }. The group takes an aria-label and marks each button aria-pressed — never role="tablist", which would promise arrow-key roving focus and a tabpanel that do not exist.

Switches

h-[1.15rem] w-8 (sm: h-3.5 w-6), checked paints --primary, unchecked paints --input. Use for a binary state that applies immediately — display on/off, a flag. A switch that needs a Save button is a checkbox.

Disabled is bg-muted and cursor-not-allowed, and if a switch is disabled for a REASON (the last visible item, a root admin), say the reason in a tooltip or a badge beside it.

Badges

rounded-full, 8 px horizontal padding, 22 px tall, Inter 500 at 12 px, five variants (default, secondary, destructive, outline, ghost). The house pattern for a state or a role is variant="ghost" plus mono-caps plus a tint:

<Badge variant="ghost" className="mono-caps rounded-[var(--radius-badge)] bg-[rgba(var(--brand-accent-rgb),0.12)] text-[var(--accent-ink)]">
  admin · root
</Badge>

A badge states a fact. If the reader can change it, it should be a control; if they cannot, a badge with the reason on hover beats a disabled control.

What not to do

  • No shadow on a resting button. Elevation means "this floats", and it does not.
  • No emoji in a label, no icon that repeats the label ("💾 Save").
  • No full-width primary buttons in product UI. That is a marketing CTA.
  • No two primaries side by side, ever.
  • No title attribute as a substitute for a real Tooltip on an interactive control (the title is fine as extra precision on a figure).