Skip to content
Brand guide

Chapter 11

Components

The library and how to choose within it. Live examples for every component are in the component gallery; the reference implementations are in components/ui/ and copy into a Since Labs product as-is.

11.1 Where each thing is written down

Four surfaces, one source each. Edit the right one and the other three follow.

WhatLives inRead it when
Doctrine — how to choose, what a component isthis chapterDeciding what to build
Per-component API, props, rulescomponents/registry.mjsDocumenting a component
The implementationcomponents/ui/*.tsxChanging 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 twoThe questionAnswer
Checkbox vs SwitchIs there a Save button?Save button → checkbox. A switch is the commit
Badge vs StatusChipCan it be "active" or "failed"?Yes → chip, and the chip has a dot
Tabs vs SegmentedControlDoes it control a labelled panel?Panel → tabs. A value → segmented
Tabs vs separate pagesAre these views of one thing?Different things are different pages
Toast vs AlertDoes the person have to decide something?Decision → alert, on the surface, where it stays
Modal vs DrawerMust the rest of the page be unusable?Yes → modal. Navigation is never a modal
Select vs DropdownMenuDo the items do things or set a value?Do → menu. Set → select
Spinner vs SkeletonDoes the incoming content have a shape?Shape → skeleton. Under ~300ms → neither
Tooltip vs body copyDoes the reader need it to succeed?Need → put it on the page. A tooltip is not reachable on touch
Disclosure vs always-openIs 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.

VariantFillTextUse
primaryaccent-ink #A34A1FwhiteThe one action the page exists for. One per view
secondarywhite, border-strongink-bodyEverything alongside the primary
ghostnoneink-mutedTertiary controls in dense chrome
destructiveerror-bg, error-dot bordererror-textOnly 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. Never OK, Submit, Yes, Learn more.
  • type="submit" is required inside a form. The component defaults to type="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>. Use buttonVariants().
  • 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.


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 #FFF or bg-white from Tailwind's default palette instead of the white token, which is --sl-surface and becomes #232120 in 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.ts and components/registry.mjs, with an example in the gallery — npm run check enforces the first two

This chapter as plain markdown: /docs/components.md