Components
The console component kit, page recipes, loading, empty and error states.
About the primitive layer
The reference apps use shadcn/ui — the console on Radix
primitives, filavirtual/apps/admin on Base UI. Both
generate from the same token set, which is why they look identical despite
different primitive libraries.
None of that is required. What is required is the behaviour described on this page and the token conformance described in Foundations. A Svelte app with hand-written components is conformant; a React app that installs stock shadcn without the tokens is not.
If you do use shadcn, two notes from the reference implementations: generate against the vendored token file rather than a fresh theme, and expect to compress the default sizes — stock shadcn's 36px control height is one step taller than this system's 32px.
The console kit
These are the components every console app needs. They encode decisions, not
just markup, which is why they are worth specifying rather than leaving to each
app. The reference implementations live in apps/console/src/components/ and
are copied verbatim into apps — treat them as shared code to keep in sync, not
as a starting point to evolve locally.
Pending button
Rule. A button that enters a pending state must not change width.
Swapping "Save" for "Saving…" relayouts the button and often the row around it, right at the moment the user is watching to see whether their click registered. Stack both labels in the same grid cell so the button is sized by the wider of the two, and crossfade between them:
<button class="…">
<span class="grid place-items-center">
<span class="col-start-1 row-start-1 inline-flex items-center gap-1.5 transition-opacity duration-150">
Save
</span>
<span class="col-start-1 row-start-1 inline-flex items-center gap-1.5 opacity-0 transition-opacity duration-150">
<svg class="size-3.5 animate-spin">…</svg> Saving…
</span>
</span>
</button>Toggle opacity-0 between the two spans, and mirror it with aria-hidden so
assistive technology announces one label, not both. The button is disabled while
pending.
Settings section
Rule. One card per request. Save is disabled until something changed.
A settings page with a single Save button at the bottom makes the user guess which fields a failure applies to, and forces one request to carry unrelated changes. One card per concern maps each save to one API call and one error.
┌──────────────────────────────────────────┐
│ Identity │ title
│ How this project is named and addressed. │ description
├──────────────────────────────────────────┤
│ [ Name ] │
│ [ Slug ] │ content
├──────────────────────────────────────────┤
│ Slug must be lowercase. [ Save ] │ hint · save
└──────────────────────────────────────────┘The section is deliberately not a <form> element: section content may
embed its own action forms (an invite row, a delete button), and forms cannot
nest.
Track edits as an overlay on live data rather than copying the entity into local
state: keep a map of changed keys, derive dirty from whether that map is
empty, and send only the changed keys. Background refetches then stay visible in
untouched fields instead of being masked by a stale snapshot, and a partial save
does not have to reconcile a whole object.
Confirm dialog
Rule. A confirmation stays open if the action fails.
If the dialog closes optimistically and the request then throws, the user gets an error toast about something that is no longer on screen, with no way to retry except finding the row again. Keep the dialog mounted until the action resolves successfully.
Destructive confirmations name the object in the title (Remove backup.acme.com?)
and use the destructive variant for the confirm button only — the cancel button
stays neutral and is the default focus.
Embedded apps should route confirmations through the host modal rather than rendering their own, so the dialog is centered on the console viewport rather than inside the iframe.
Status badge
One mapping for the whole domain vocabulary. See Status colors.
Humanize labels at the badge, not at the call site: no_show renders as
"No-show", everything else replaces underscores with spaces. One place to fix a
label is worth the indirection.
Error and loading states
// A failed read: the message, and nothing pretending to be data.
ErrorAlert → destructive alert, title "Could not load", body = the message
RowsSkeleton → n × full-width skeleton bars at control height, gap-2Empty state
Always the same composition, so an empty page is recognisable as empty rather than broken:
▣ icon in a muted square
No projects yet
A project groups the experiences your guests
join. Create one to get started.
[ + New project ]- Title names what is missing.
- Description is one sentence on what the object is and why you would make one — this is the only place many users learn the domain vocabulary.
- Action is the primary creation affordance. Omit it when there is nothing the user can do from here (a filtered log with no matches).
- Vary the copy by filter scope: "No projects yet" and "No active projects" are different situations and one of them has an obvious next step.
Empty states use a dashed border to read as a placeholder rather than a card.
Loading
Rule. Loading is a layout-matched skeleton, never a centered spinner.
From the console's own skeleton module:
Every route renders a skeleton that mirrors its real layout (same containers, same table columns), so a loading page reads as the page it's about to become — never a blank screen or a centered spinner.
A skeleton that mirrors the page prevents the layout shift when data lands, and tells the user what is coming. A centered spinner tells them only that something is happening, and it looks identical on every route.
Practically:
- Lists and sections → skeleton rows at the real row height.
- Tables → a skeleton table with the same columns; pass column widths so the bars line up with the real header.
- Dense dashboards → mirror the real grid, including the tile count.
- Buttons and inline actions → the pending-button pattern, not a skeleton.
- First paint of an embedded app → nothing. The host already covers the iframe with an overlay and spinner until the handshake completes; your own full-screen loader would be a second wait stacked on the first.
Spinners are for in-place pending states inside a control, and they run at
0.6s, not the default 1s.
Page recipes
List page
PageHeader ─ title, description, primary action
body ─ exactly one of: skeleton │ error │ empty │ data
dialogs ─ rendered unconditionally, controlled by open stateResolve the four states in one place, in this order, and assign to a single variable that the page renders once:
let body;
if (query.isPending) body = skeleton;
else if (query.isError) body = errorAlert;
else if (data.length === 0) body = empty;
else body = table;The ordering matters: an error must win over an empty state, or a failed request renders as "no results" and the user never learns it failed.
Render create dialogs at the bottom of the page, always mounted, driven by an
open flag — not conditionally inside the action button, where the exit
animation cannot play.
Detail page
PageHeader ─ eyebrow (parent object) · title with inline status badge · actions
alert ─ only when the object is in a degraded state, with a link to the fix
stat strip ─ <dl> hairline grid, 2 columns → 4 at md
sections ─ PageSection per related collectionUse a description list for stats — it is the correct semantic and it gives screen readers the label/value pairing for free.
Settings page
Stack settings sections. If there are more than about four, split them across tabs — and give every tab a real URL so a specific setting can be linked to. Derive the active tab from the pathname rather than from tab component state, so the URL is the single source of truth.
Dialogs and sheets
| Use | For |
|---|---|
| Dialog (centered) | Create flows, short confirmations, previews |
Sheet (side panel, max-w-md) | Editors and read-only drill-ins with more than a few fields |
Rule. Mount a sheet or dialog body only while open, and key it by the record being edited. Otherwise the previous record's values leak into the next opening, and a form that was dirty when dismissed reopens dirty.
Sheets carry their own scroll container (min-h-0 flex-1 overflow-y-auto) for
the same reason pages do.
Tables
Plain semantic <table> markup. A headless table library buys column
virtualization, sorting and grouping — none of which the reference console pages
need, and all of which cost markup you then have to make accessible again.
| Cell | Convention |
|---|---|
| Primary | A link, hover:underline, often two lines: name in font-medium above a muted secondary identifier |
| Numeric / date | text-muted-foreground tabular-nums |
| Status | Status badge |
| Actions | <th class="w-0"> with a visually-hidden "Actions" label; right-aligned ghost buttons at xs |
Rows get hover:bg-muted/50. Keep the row highlighted while its action menu is
open, so the menu stays visually attached to its row.
If a whole row opens something, put the click handler on the row and a real
focusable element in the first cell — a cursor-pointer row is not reachable by
keyboard on its own.
Wrap tables in the standard surface:
<div class="bg-card ring-foreground/10 ring-1">
<table>…</table>
</div>Forms
Rule. Validation is server-authoritative. Field errors render inline; everything else becomes a toast.
Client-side validation duplicates rules that the server must enforce anyway, and the two drift. Use client-side checks only to disable a submit button on obviously incomplete input — never as the source of the error message.
Map each server error code to the field it belongs to, in one table per app:
const FIELD_BY_CODE = {
invalid_slug: "slug",
slug_taken: "slug",
invalid_timezone: "timezone",
};Errors with a field render under that field. Errors without one render at the bottom of the field group. Transport and unexpected failures become a toast — they are not the user's input problem.
Markup for an invalid field:
<div data-invalid="true">
<label for="project-name">Name</label>
<input id="project-name" aria-invalid="true" aria-describedby="project-name-error" />
<p id="project-name-error">Name is required.</p>
</div>aria-invalid is what the styling keys off, so the visual and assistive
signals can never disagree.
Accessibility
- Focus rings are a 1px ring in
--ringat 50%, on:focus-visibleonly. Never remove an outline without replacing it. Set a global fallback outline so an element that slips past a component still shows focus. - Accessible names on every icon-only control. Include the object:
Actions for Main queue, notActions. - Decorative icons beside a visible label are hidden from assistive tech.
- Live regions for anything that updates without user action — a polling board's freshness indicator, a countdown.
- Landmarks: real
<header>,<main>,<section>,<nav>elements, and<dl>for stat pairs. - Keyboard shortcuts need to be discoverable.
Cmd/Ctrl + Btoggling the sidebar is invisible unless the trigger's tooltip says so. - Do not lock zoom.
width=device-width, initial-scale=1and nothing more.