11.1 Where each thing is written down
Four surfaces, one source each. Edit the right one and the other three follow.
| What | Lives in | Read it when |
|---|---|---|
| Doctrine — how to choose, what a component is | this chapter | Deciding what to build |
| Per-component API, props, rules | components/registry.mjs | Documenting a component |
| The implementation | components/ui/*.tsx | Changing behaviour |
| The same thing as data | /components.json (generated) | You are a machine |
npm run check fails if the registry and the exports disagree, so a component
cannot be half-added.
The whole set imports from one path:
import { Button, Panel, StatusChip } from "@/components/ui";
11.2 The rule that catches everyone
#C4602A is a fill. It is 4.16:1 on white, which fails WCAG AA for text.
#A34A1F is what you read.
That single substitution — text-terra → text-terraink, bg-terra →
bg-terraink on anything carrying a label — is the most common brand defect in
Since Labs code. The audit counted 62 of them in the product. Every
component in this system already gets it right; the risk is in what you write
next to them.
11.3 Choosing between neighbours
Most component mistakes are a correct component in the wrong slot. These are the pairs that get confused, and the question that settles each one.
| These two | The question | Answer |
|---|---|---|
Checkbox vs Switch | Is there a Save button? | Save button → checkbox. A switch is the commit |
Badge vs StatusChip | Can it be "active" or "failed"? | Yes → chip, and the chip has a dot |
Tabs vs SegmentedControl | Does it control a labelled panel? | Panel → tabs. A value → segmented |
Tabs vs separate pages | Are these views of one thing? | Different things are different pages |
Toast vs Alert | Does the person have to decide something? | Decision → alert, on the surface, where it stays |
Modal vs Drawer | Must the rest of the page be unusable? | Yes → modal. Navigation is never a modal |
Select vs DropdownMenu | Do the items do things or set a value? | Do → menu. Set → select |
Spinner vs Skeleton | Does the incoming content have a shape? | Shape → skeleton. Under ~300ms → neither |
Tooltip vs body copy | Does the reader need it to succeed? | Need → put it on the page. A tooltip is not reachable on touch |
Disclosure vs always-open | Is it secondary for most readers? | Most readers need it → do not hide it |
11.4 Layout
AppShell — the product frame: a recessed rail on paper-base, a content
column on paper, and the same rail behind a Drawer on a phone. Use it rather
than rebuilding: it carries the skip link, the <main id="content"> that link
targets, and aria-current="page" on the active row. Pass your router's link
component as renderLink; the shell does not know your router.
PageHeader — breadcrumb, eyebrow, h1, description, actions. Every page
opens with exactly this, and it supplies the page's only h1. Hand-rolling a
heading beside it is how two pages end up with two title sizes. The audit found
eleven pages with no h1 at all.
Card / Panel — Card is a bare container: white, hairline border, 2xl
radius, raised shadow. Panel adds a titled header rail. Prefer Panel —
content worth boxing is almost always worth naming. headingLevel is caller-set
so the outline stays correct. Never nest a Panel in a Panel; two borders one
inside the other means the page needs splitting.
Separator — decorative by default. A rule between two cards is not
information, and a screen reader announcing "separator" between every pair of
rows is noise.
Eyebrow — the tracked-out uppercase label, the one place the brand uses
capitals. Always above a heading, never alone, never a button. slash prefixes
the // mark: one per surface.
11.5 Actions
Button — four variants, three sizes.
| Variant | Fill | Text | Use |
|---|---|---|---|
primary | accent-ink #A34A1F | white | The one action the page exists for. One per view |
secondary | white, border-strong | ink-body | Everything alongside the primary |
ghost | none | ink-muted | Tertiary controls in dense chrome |
destructive | error-bg, error-dot border | error-text | Only where something is actually destroyed |
Sizes: sm 32px (table rows, toolbars), md 38px (inline chrome), lg 48px
(forms, auth, marketing — the default).
- The label is a verb phrase completing "I want to…":
Save changes,Send invite,Delete organization. NeverOK,Submit,Yes,Learn more. type="submit"is required inside a form. The component defaults totype="button"and fails silently otherwise — no console error, no visual difference.- Disabled: 50% opacity,
cursor-not-allowed. Keep it in the DOM. - A link that looks like a button stays an
<a>. UsebuttonVariants(). - Icon-only buttons need
aria-label.
DropdownMenu — actions hanging off a trigger, with the ARIA keyboard
contract: arrows move, Home/End jump, Escape closes and returns focus, Tab
closes and moves on. It closes on outside pointerdown, not click — bound on
click, the press that opens it also closes it.
CopyButton / CopyField — the confirmation is announced in a live region,
not only drawn. Clipboard access fails on an insecure origin, and the failure
surfaces as "Press ⌘C" rather than a false success.
TextLink — links are accent-ink, underlined in prose. Colour alone is not
a distinguishing feature (WCAG 1.4.1). External links get
rel="noopener noreferrer" and say they open a new tab.
11.6 Forms
Field + useFieldIds — the label / hint / error scaffold every control
shares. Build anything this set lacks on top of it, and the aria-describedby
wiring comes for free. An error replaces the hint rather than stacking under it:
two competing instructions is worse than one correct one.
Input, Textarea, Select — the label is always rendered and tied by
id. A placeholder is not a label; it disappears the moment someone types,
which is exactly when they need it. The placeholder shows a format —
[email protected] — not a repeat of the label.
Select is a native <select> deliberately. A custom listbox is 200 lines of
keyboard handling, a focus trap and a portal, and still loses to the OS picker
on a phone.
Checkbox / Radio — real inputs under styled boxes, so they survive
Windows High Contrast Mode, where a div painted with background colours
disappears. Two or more related controls go inside CheckboxGroup /
RadioGroup, which render the <fieldset> and <legend> that tell a listener
these answer one question. Never ship a lone radio: unlike a checkbox, it cannot
be unset by the person who set it.
Switch — takes effect immediately. If the page has a Save button, the
control is a checkbox.
11.7 Navigation
Tabs — roving tabindex is the part people skip: only the selected tab is in
the tab order, and arrows move between them. Leave every tab at tabIndex={0}
and a keyboard user presses Tab eight times to get past a tab bar. Only the
selected panel renders; if a hidden panel holds a form, render them all and
toggle hidden instead, or you throw away what the person typed.
SegmentedControl — two to four short options that set a value. Five or
more, or anything longer than two words, wants a Select.
Pagination — states the range in words: 1–25 of 340. "Page 2 of 14" does
not tell anyone whether the row they want is ahead or behind. Pass hrefFor to
render links, which keeps the back button, middle-click and sharing.
Disclosure — built on <details>/<summary>, so find-in-page opens a
closed section containing the match. Deliberately not an accordion: opening one
does not close its siblings, because a person cannot compare two sections that
close each other.
11.8 Data
DataTable — the overflow-x-auto wrapper is not optional; without it a
wide table pushes the whole page sideways on a phone. The caption is required
by the API and visually hidden: it is what tells a screen reader user what the
table is before they start arrowing through cells. The audit found 16 tables and
0 captions. Figures are tabular-nums so a column of them lines up.
DescriptionList — label-and-value pairs as a real <dl>, so "Plan: Team"
is read as one thing rather than two unrelated cells.
StatTile / StatGrid — a dt/dd pair, so the label and its figure are
read together. accent marks one tile per grid, at most.
EmptyState — three parts: what is not here, why or what to do, and the
control that does it. The title is never a joke; it states what is missing.
11.9 Status and feedback
Alert — an inline message about the surface it sits on, in six tones.
error and warning announce themselves; the quieter tones do not, so a page
full of informational notes does not shout over a screen reader on load.
StatusChip — the dot is not decoration. It is the redundant, non-colour
channel that makes the chip work in greyscale and for a colour-blind reader.
Never ship a status pill that is a bare colour swatch. toneForStatus is
centralised so one status value cannot render as success in one table and
neutral in another.
Badge — a short static label: a count, a plan name, a version. Never
interactive; a clickable badge is a button that looks like a label, and nobody
clicks it.
Progress — determinate whenever you can say. progressbar with no
aria-valuenow is how a screen reader is told the total is unknown, so
indeterminate is a real state (value={null}), not a styling variant. The fill
animates with scaleX, never width.
Spinner — one second, linear, forever. Prefer Skeleton for content that
has a shape, and prefer nothing under ~300ms: a spinner that flashes for one
frame reads as a glitch.
Skeleton — shape it honestly. A 200px block where a three-line paragraph
will appear is worse than a spinner, because it promises a layout it then
breaks. aria-hidden: the loading state belongs to the live region around the
fetch.
ToastProvider / useToast — reports the result of an action the person
just took. Not a notification channel, and never the only record of anything
important: it disappears fastest for the people reading slowest. Errors do not
self-dismiss.
11.10 Overlays
Modal / ConfirmDialog — a native <dialog> opened with showModal(),
which puts it in the browser's top layer, outside the flow, where no
ancestor's overflow can clip it. That is not a nicety: a popover inside a
Card with overflow-hidden is cut off at the card's edge with no way to reach
the confirm button. The element also brings the focus trap, Escape, aria-modal
and inert-ing of the page behind it.
A modal appears instantly. It is a mode change; a fade makes the whole application feel slow.
ConfirmDialog's confirm button names the action — Delete organization, never
OK. A person in a hurry reads only the button.
Drawer — 200ms translateX with the backdrop fading over the same
duration. The only full-distance translate in the system. It is not a modal
<dialog> deliberately: the commonest drawer is a mobile nav rail, and making
the page inert is wrong for navigation — so the focus trap is ours, and it is
there.
Tooltip / InfoTip — opens on focus as well as hover, closes on Escape,
and describes rather than labels, so the trigger keeps its own accessible name.
Never the only place information exists.
11.11 Identity
Avatar — initials on accent-ink, from a name, falling back to an email.
aria-hidden: the person's name is already beside it.
Icon — one 24×24 grid, 2px stroke, round caps, no fills. That geometry is
what stops a row of icons looking like a row of icons from four sources. Never
scale a 16px icon up — the stroke thickens with it. Decoration by default; when
an icon is a control's only content, the accessible name goes on the control.
ThemeToggle — light / system / dark. Three states, because "system" is a
real choice and the default; a two-way toggle takes something away the moment
someone touches it. It requires THEME_SCRIPT in <head> — see
Adoption § 15.2 — or the page paints in the wrong theme
before correcting itself.
11.12 Both themes are the brand
A component is not finished until it has been looked at in both. The dark theme
is not a variant bolted on: the brand inverts, and every token has a dark value
that has been checked for contrast by npm run check.
What breaks when a component is built light-only:
- Hard-coded
#FFForbg-whitefrom Tailwind's default palette instead of thewhitetoken, which is--sl-surfaceand becomes#232120in dark. - A status fill taken from the light palette, so a chip becomes cream-on-cream.
- A chart mark that is near-black ink, invisible on an ink ground.
- A logo lockup that is the wrong file for the ground it lands on.
Use the toggle in the guide's sidebar on the gallery page before you ship.
11.13 Component checklist
Before a new component is merged:
- Uses only tokens — no hard-coded hex, no Tailwind default palette
- Text colours clear 4.5:1; UI boundaries clear 3:1
- Correct in both themes
- Keyboard reachable; visible focus ring; correct tab order
- Labelled for assistive tech (
aria-label,aria-describedby,scope,caption) - Has designed empty, loading and error states where it can have them
- Nothing communicated by colour alone
- Works at 375px wide
- Hit area ≥ 44px
- Honours
prefers-reduced-motion - Sentence case, verb-phrase labels
- Server-safe unless it genuinely needs
use client - Added to
components/ui/index.tsandcomponents/registry.mjs, with an example in the gallery —npm run checkenforces the first two