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
| Variant | What it is | Use 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.
| Size | Box | Notes |
|---|---|---|
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.
defaultkeeps its fill and gains a 2px ring;quietandghostpaint--accent(the accent tint, not grey);destructivegains an offset outline. Hover never changes the label's colour. - Focus.
focus-visibleonly: the border becomes--ringand a 3px ring is painted. Never remove it, never replace it withoutline: 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— pluscursor-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 (Publish→Published) 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.
| Control | Use it for | Cap |
|---|---|---|
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
titleattribute as a substitute for a realTooltipon an interactive control (thetitleis fine as extra precision on a figure).