Overlays, and the primitives not covered elsewhere
All 31 primitives ship in components/ui/. Buttons, inputs, selects, switches,
badges and the table have their own documents; this is everything else, with the
rule that governs each.
Dialog
Centred, max-w-lg (calc(100% - 2rem) on a phone), 8 px radius, 24 px
padding, 16 px internal gap, --popover fill, shadow-modal (the top
elevation tier). The close button is a 44 px target in the corner with a
16 px glyph.
Use a dialog for a decision that must be made now. Never for a form with more than a handful of fields — that is a screen. Never nest one inside another.
Sheet
The same content on a slide-in panel: 75% width capped at sm from the side,
full width from top or bottom, --popover fill, shadow-modal, a border on the
edge it is attached to. Right is the default.
Use a sheet for a detail view beside the thing that opened it, and for the mobile rail (the sidebar primitive already does this — you do not wire it).
Breadcrumb
Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink,
BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis — the header's
"where you are". The last step is a BreadcrumbPage, never a link.
Popover and dropdown menu
--popover fill, shadow-overlay (one tier below modal), 8 px radius, 4 px
padding, items at 6 px radius with the accent tint on focus. Menu items take a
16 px leading icon and 8 px gap.
variant="destructive" on a menu item is for the one irreversible action, and
it goes under a separator.
The full set: DropdownMenu, DropdownMenuPortal, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubContent.
Collapsible
Collapsible, CollapsibleTrigger, CollapsibleContent — a disclosure. In the
rail it turns a nav row into an expandable group, and there the trigger is its
OWN 20px control rather than the row: clicking the label must still navigate, so
one target means one action. A group opens on its own when the current path is
inside it.
Tabs
Two variants, and line is the default: transparent list, 4 px gaps, the
active tab marked by a 2 px bar in --foreground under it. default is the
enclosed pill set on --muted, 36 px tall — use it only inside a card where a
line would compete with the card's own edge.
Tabs are for views of one thing. The moment a tab is a different subject, it is a route (this is why "How it works" left the admin screen and became a documentation page).
HoverCard
HoverCard, HoverCardTrigger, HoverCardContent — a PREVIEW, where a tooltip
would be a paragraph.
The line between them is the 160-character cap: a tooltip is one or two sentences a reader takes in at a glance, so anything with several FIELDS in it — a county's FIPS, its homes, its metro — needs this instead. Same popover surface and overlay elevation; the difference is that this one holds structure.
Not for anything a reader has to act on: it is dismissed by moving the mouse.
ConfirmDialog
The one thing every destructive Button needs to open. It names what will
happen in its title, keeps the irreversible action as the primary, and carries
its own pending state — so a slow delete does not look like a dead button.
Never for a decision that is reversible: a confirm on a safe action teaches people to click through confirms.
Field
Field, FieldLabel, FieldDescription, FieldError, plus FieldGroup,
FieldSet, FieldLegend, FieldSeparator, FieldContent, FieldTitle.
A label, a control, its help text and its error, wired together for
accessibility. Pairing a Label and an Input by hand works right up until
the error text, which then has nothing pointing at it and is never announced.
Use this for any form field that can fail.
Checkbox and Textarea
Checkbox is a boolean that waits for Save; a Switch is a boolean that
applies immediately. That is the whole distinction and it decides which one.
Textarea is the multi-line field. It exists so a fourth hand-rolled copy does
not.
Progress and InlineSpinner
Progress is a determinate bar for a job with a known end — an upload, a quota,
a queue — and it is always beside its figure: a bar with no number is a guess
dressed as a measurement. It is not Metric's progress, which is a 5px track
against a target.
InlineSpinner is the indeterminate one, at three sizes, for inside a button or
a row.
InlineEmpty
InlineEmpty / InlineEmptyProps — the compact empty, for a slot INSIDE
something: a chart with no data, a table body, a panel. The full EmptyState is
a Card, and a Card inside a Card is what the layout rules forbid.
Toaster
Transient confirmation, from sonner — and it has exactly one job: something succeeded and needs no action. "Copied." "Link sent."
A failure never goes in a toast. It disappears and takes the only record of
what went wrong with it; failures are an Alert on the screen, beside the thing
that failed. If the reader has to do anything about it, it is not a toast.
Tooltip and InfoHint
InfoHint is the (?): a tooltip trigger with a real accessible name, for
column headers and stat labels. Keep the body to one or two sentences and cap it
at ~42 characters wide.
Never put information a reader needs to ACT in a tooltip.
Slider, Toggle, ToggleGroup
- Slider for a value where the shape matters more than the number, and it always shows the number beside it.
- Toggle / ToggleGroup for 2–4 mutually exclusive views. More than four is a
Select; two that are on/off is a Switch.
A group carries its own 1px edge and the items join into one segmented
control; selected is an ink rule rather than a deeper fill. Both rules, and
the third control in the family (
CountFilter), are in04-buttons-and-controls.md.
MultiSelectFilter
A dropdown of checkboxes over a noun ("3 counties"), with a clear-all. Use it
when a filter has more options than a ToggleGroup can hold. The trigger states
the count and the noun, never a list of truncated labels.
StatusDot
A 8 px dot in one of four tones — positive, caution, negative, neutral —
optionally with a label. It is the smallest state device in the system: use it
in a dense row where a badge would be too loud, and never as the only cue
(the label beside it carries the meaning for anyone who cannot see the colour).
KpiCard
The card-shaped figure: eyebrow, value, optional footer and info. StatTile
in components/brand/devices.tsx is the leaner version and the one to reach for
first; KpiCard earns its extra weight when the tile needs a footer, an action
or a loading skeleton.
TablePagination
Page sizes 10 / 25 / 50, first / prev / next / last as icon-sm ghost buttons,
and the range stated in words ("26–50 of 1,894"). 44 px tall — it is part of the
table's height budget (07-tables-and-data.md).
Skeleton, Avatar, Separator, Label
- Skeleton: shaped like the thing that is coming. Two tokens
(
--skeleton,--skeleton-pulse), so it is never a grey block on a warm page. - Avatar: round, two-letter fallback, and
referrerPolicy="no-referrer"on any photo from a provider that 403s an unexpected referer — otherwise Radix shows the fallback and it reads as "no photo". - Separator:
--border. A rule separates; it never decorates. - Label: pairs with an input by
htmlFor. Every input has one, even when the design hides it (sr-only).
AnimatedIcon
A 200 ms rotate-and-scale on hover or focus, motion-safe only, with a group
trigger so a whole tile can drive it. Reserved for something that goes
somewhere — a tile that links, a row that opens. Motion on a static icon is
noise.