Components
The full component spec — 46 sections, on Base UI — with live examples for what's actually built in @quoin/ui.
Every component is built on @base-ui/react (1.8.0). Every component has the own skin of Quoin: calm, printed,
hairline borders, small radii, and no drop shadows except on popups.
Shadow
Popups get exactly one shadow in the whole system. Popups are menus, selects, comboboxes, dialogs, tooltips, popovers, drawers and toasts. The shadow is:
/* --shadow-popup — the only elevation in the system */
box-shadow: 0 0 0 1.5px var(--color-fg), 0 12px 32px -12px rgb(30 26 21 / 0.15);The value comes verbatim from the "Confirm · resume" dialog of Quoin in Paper (node 5DJ-0). Tooltip, PreviewCard
and Menu row actions are non-modal, lighter popups. These popups can drop the 1.5px fg ring. They then keep only
the offset shadow at half strength.
Built today
@quoin/ui has nineteen components now. It has twenty exports, because Field and Form ship as a pair. Every
example below is live. Each example imports the real component, not a picture of the component.
Button
A Button has six variants: primary, accent, secondary, ghost, destructive and link. It has two sizes. The label is
always centered (display: flex; align-items: center; justify-content: center). Quoin has no left-aligned button
label.
Two of the variants are dark. The two variants are not interchangeable.
Primary is ink (--color-fg on --color-surface). It is the one dark button on a product screen, including
"Create workspace" on S1. Accent is the navy --color-accent on --color-surface. It exists for exactly one
place: the headline CTA of the auth flow. The CTAs are "Email me a code" on /login (A1) and "Continue" on
/login/check-email (A2). Paper BFF-0 and BT8-0 draw them (07d qn01-qa6-1). On hover, accent is the same
colour at 90%. When disabled, accent is at 35%. This matches the pattern of primary.
Use primary everywhere else. Two accent buttons in one product view are a bug. On all three dark fills (primary,
accent, destructive), the keyboard focus ring is drawn in --color-surface. The usual accent ring would vanish
against the fill (07d qn01-sync-2).
IconButton
Every icon-only action carries an aria-label. No icon-only button exists without one.
Input
Single-line text entry, built on Input of Base UI. In real screens, it is always inside a Field.Root. It has
two sizes (sm 28px, md 34px). The border is 1px --color-border-strong at rest. The border is 1.5px
--color-accent when the field has focus. The border is 1.5px --color-negative when the field is invalid. The
border changes, never the background. Read-only and disabled fields both sit on --color-surface-2.
Input takes its own autoFocus prop for the first field of a screen (the email on /login, the name on
/workspaces/new). The prop moves DOM focus itself and marks the ring visible immediately. A ref.focus() call in
the page moves focus. But Chromium does not grant :focus-visible to a scripted call that has no user gesture
behind it.
Field, Form
The wrapper that every control sits inside. Field.Root carries the name that matches the error key of the
server. Field.Label is --text-sm medium. Field.Description and Field.Error share one --text-sm slot 4px
under the control. A field shows one or the other, never both. Form is a column with --space-4 between
fields.
Avatar
Three sizes: inline (in a line of text or a chip), row (a table row, a comment) and profile (settings, the
member list). The avatar falls back to initials after a 200ms delay. An image that loads fast then never flashes
initials first.
Select
A closed list of options. Use it for the environment picker inside forms, for plan tiers, and for any other small, fixed choice. Do not use it for the environment switcher in the sidebar. That switcher is a distinct, always-visible control (see Design rules).
An option can also carry a one-line description under its label, in --text-xs muted. An option can be
disabled. A disabled option turns both lines --color-fg-subtle. The role picker is the reference. Owner / Admin
/ Editor / Viewer each have their own sentence. In the invite pickers (S4 and the Invite dialog of T3), Owner is
listed but disabled. The reason ("Promote someone to owner from Members & roles.") is in its description slot. An
invite never carries the owner role (07d qn11a-plan-2, docs/plans/qn-11a.md Amendment 6). The visual reference is
the open popup on Paper 8I2-0. OO1-0 draws the same Select closed on Editor.
Separator
Two weights: default (--color-border) and strong (--color-border-strong). Use strong to divide a busier row
from a quieter row.
Sidebar nav item
The building block of the primary sidebar. It has an icon, a label, an optional count at the end, and the
aria-current="page" hook of Base UI for the active state. There is no separate "active" prop to keep in sync by
hand.
Banner
An inline, page-level notice. It is not Base UI. It has no timeout. A page has one Banner at most. It has three
variants. Each variant is a subtle fill with the ink of the page on top. info is on --color-accent-subtle (the
invite-flow entry on /login). caution is on --color-caution-subtle. negative is on
--color-negative-subtle (a failed submit). A key inside the sentence takes .qn-banner-key and gets the strong
colour of the variant.
Use a Banner for a notice about the page. A message about one control is Field.Error or plain caution text under
that control. Never use a Banner for it. Examples are a wrong code, a taken URL, and a rate limit on the button
that the person just pressed. The design rule is "caution is text only". Paper GCK-0 draws the rate-limited
state that way.
OTP Field
The six-digit sign-in code on /login/check-email (A2), built on otp-field of Base UI. It is component 46. At
desktop width, it has six 52×56 slots in --font-mono 22px/28px with an 8px gap. A paste of six digits fills all
six slots. The focused slot takes the 1.5px --color-accent border. When the field is invalid, every filled slot
takes 1.5px --color-negative. The sentence about attempts left appears as Field.Error.
Below desktop width, the six slots shrink evenly to fit the row inside the pane. They do not force the page wider
than the viewport. The gap and the text size do not change. The visual reference is section 7 of Paper GET-0.
CopyChip
The signature "command chip". It is component 3 and the fifth of the five signature elements of Quoin. It is not a
Base UI component. It is a styled Button primitive. It wraps the full command in --font-mono 12px/16px on a
--color-surface-2 well. The copy glyph is at the end of the chip, in --color-fg-subtle. The whole chip is the
affordance. There is no "Copy" word next to the icon. The command is never truncated. When the row is narrow, wrap
the command in a ScrollArea.
One click writes the command to the clipboard. The click also holds a data-copied state for 1.6 seconds. The
glyph crossfades to a --color-positive check with a one-pixel lift. Then the glyph reverts on its own. A rapid
re-click restarts the hold. It does not cut the hold short. A clipboard that refuses never throws. Under reduced
motion, the crossfade keeps its opacity swap and drops the lift.
The icon variant is the same control with no visible command. It is the copy button beside a shown-once key or
beside the filename of a code block. It then takes its own aria-label. The visual reference is the step-1 chips of
S2 on Paper 710-0 and the top-right chip on 919-0.
ScrollArea
Component 30, built on ScrollArea of Base UI (Root/Viewport/Content/Scrollbar/Thumb/Corner). It is a styled
scroll surface for a long list inside a fixed-height panel. Examples are popup lists, table bodies of more than
about six rows, and a code block that is wider than its column. Where Quoin uses a ScrollArea, it hides the native scrollbar chrome.
The thumb is --color-border-strong at rest on a transparent 8px track. It is --color-fg-muted on hover or
during a scroll. It fades in only when the pointer is over the area or the area moves. orientation is vertical
(the default), horizontal or both. Never wrap a whole page in a ScrollArea. Page-level scroll stays native.
CodeBlock
A composition, not a 46th inventory section. It has these parts:
- The
--color-surface-2well on S2 (Paper710-0) - A filename in
--font-mono12px--color-fg-subtle - An icon CopyChip labelled
Copy src/flags.ts(the filename it sits over) that copies the raw code - The code, in a horizontal ScrollArea. A long line then scrolls inside the block and does not widen the page.
CodeBlock takes the highlighted markup as html. The app highlights on the server with Shiki and the own token
colours of Quoin. The example above passes plain <pre><code> markup. The block shows this markup when no
highlighter runs.
Table
| Role | Last active | |||
|---|---|---|---|---|
| Mia Okafor | mia@tessellate.io | Owner | now | |
| Tessellate needs at least one owner. Promote someone else first. | ||||
| Sam Reyes | sam@tessellate.io | Admin | 3 hours ago | |
| Dev Patel | dev@tessellate.io | Editor | 1 day ago | |
Component 35, built on TanStack Table. It uses the legacy, v8-compatible API behind the /legacy subpath (07d
qn11a-ii-2). It has the row styling of Quoin:
- A 34px header row on
--color-surface-2under a--color-border-strongrule - Body rows at
--row-height-table(40px, the token thatdocs/03-components.mdsets), separated by hairline--color-border - Sortable headers as plain buttons with a small chevron pair. The pair lights up on the sorted column.
Paper BIB-0 draws its member rows taller, at 52px. The token is what ships. To bring the canvas to the token is
TBD: for the next design pass on T3. The column definitions set the column widths. The slot for row actions is a
fixed 32px column at the end of the row. It stays in the row when it is empty. For example, the row of the
signed-in person has no "⋯". The lane then lines up down the table.
rowDetail renders a second, full-width row under any row that needs one. T3 uses it for the sole-owner caution
line, in --color-caution text, never negative. emptyState fills the body when there are no rows. The visual
reference is the member table of T3 on Paper BIB-0. 7PB-0 (T1, environments) is the second reference, with
48px rows and the same empty slot at the end of the row.
Menu
Component 15, built on Menu of Base UI (Root/Trigger/Popup/Item/Separator). It is the row-actions popup behind
a "⋯" IconButton. The popup takes --shadow-popup like every other popup. Items are 30px high on a
--color-accent-subtle highlight. A destructive item is --color-negative text on a --color-negative-subtle
highlight. It is the one negative colour on the screen while the menu is open. Its label ends in an ellipsis when
it opens a confirm, as "Remove member…" does.
By default, the popup opens below its trigger, end-aligned. side and align override that. The popup opens the
way Select does. It scales from its trigger and fades over --transition-popup-open. It closes over
--transition-popup-close. Under reduced motion, it uses opacity alone (docs/04-motion.md §3, 07d
qn11a-ii-sync-1). The visual reference is Paper P05-0 (T3, the menu open on Dev Patel's row).
Dialog
Component 20, built on Dialog of Base UI (Root/Trigger/Popup/Title/Description/Close). It is the modal shell
for a small form. The popup is 480px wide at most, with 20px of padding, --radius-lg corners and
--shadow-popup. The title is --text-lg semibold. A Close composed onto a ghost IconButton with
.qn-dialog-close-icon sits 12px inside the corner.
The panel enters over --duration-slow, scales up from 0.95 and settles up 8px. It leaves over --duration-base.
The backdrop fades in with the panel and fades out faster than it faded in (docs/04-motion.md §3, 07d
qn11a-ii-qa-6 and qn11a-ii-sync-1). Footer actions sit in .qn-dialog-actions, right-aligned, with the secondary
Cancel before the primary. Close is otherwise a plain passthrough. A footer button composed onto it stays a
footer button (07d qn11a-ii-qa-1).
The example is the Invite dialog of T3, Paper OO1-0. Work email has focus and is empty. Role defaults to Editor,
with Owner shown disabled. AFS-0 (the create-key dialog of T2) is the same shell.
AlertDialog
Component 21, built on AlertDialog of Base UI. It has the shell of Dialog, but it does not close on a click
outside and it has no corner close. The person answers Cancel or the destructive button. This is the one-click
shape: Title, Description, Cancel and one destructive Button. The destructive Button is the only negative
colour in the dialog as built. QN-11a-ii built it for "Remove member" (Paper OBX-0) and "Revoke invite"
(OHZ-0). It matches the revoke-key dialog of T2, APW-0. When a mutation fails, the dialog reports the failure
as caution text above the actions. The dialog does not close (07d qn11a-ii-qa-4).
The dialog enters and leaves as opacity alone, with no scale. It uses --duration-base in and --duration-fast
out. This is the deliberate contrast with Dialog that docs/04-motion.md §3 asks for (07d qn11a-ii-sync-1).
The canvas also draws a small --color-negative ! before the title. The build renders no glyph, and by ruling
never will (07d qn11a-ii-design-1(4), extended to every AlertDialog, not only OBX-0, by
07d qn11b-signoff-2). Each dialog already spends the one negative colour of the screen on its destructive
Button. A red glyph before the title is a second use. This is against the rule of CLAUDE.md, "negative colour at
most once per screen". The title stays a plain BaseAlertDialog.Title in --color-fg. The canvas keeps the glyph
as a drawing convention only.
The typed-key shape is the same dialog plus one mono Field. The placeholder of the Field is the key to type
back. The destructive button stays disabled until the typed value matches exactly. E8T-0 (delete workspace) and
PVU-0 (delete environment) draw this shape. T1 and T7 of QN-11b built it.
Checkbox
Component 9, built on Checkbox of Base UI (Root/Indicator). It is a hairline-bordered 16px box with
--radius-sm corners. The box fills --color-fg when checked. A stroked SVG path in the surface colour draws the
check. keepMounted on the indicator makes the check animate in and out. The check does not pop. The label is a
plain <label htmlFor> beside the box. The component ships the box alone.
Its one job in QN-11b is the "I've stored it somewhere safe" gate on the secret-shown-once Dialog of T2 (Paper
AKU-0). The primary Done stays disabled until the person ticks the box, exactly as above. The two disabled boxes
on the right are the states that a form leaves behind when its answer is fixed.
RadioGroup
Component 10, built on RadioGroup and Radio.Root of Base UI. @quoin/ui exports the pair as one RadioGroup
object (Root/Item). Use it for a single selection among a small set of named options, when every option needs
a sentence. The example is the Scope picker of T2 from the create-key Dialog (Paper AFS-0), with Server
pre-selected. Each row is a <label> that wraps the Item and a two-line label/helper stack. The whole row is
then the hit target.
The checked dot has the same ink-on-surface language as the check of Checkbox. Base UI moves focus with the arrow keys, and Space selects. Do not use it for on/off. That is the job of Switch. Even there, the state of a flag in a list is not a Switch (row 11).
The full spec
docs/03-components.md specifies everything else below, but it is not yet built. The table names what each
component is for and where it shows up. Implementation then has a spec to build against and not a blank page.
| # | Component | What it's for |
|---|---|---|
| 1 | Button | Every primary action. It has six variants and two sizes. Built. |
| 2 | IconButton | Icon-only actions with a required aria-label. Built. |
| 3 | CopyChip | A copyable CLI command, inline. It is the signature "command chip". It appears on flag pages, experiment reports and audit rows. Built. |
| 4 | Input | Single-line text entry: flag keys, notes, search. Built. |
| 5 | Textarea | Multi-line entry, specifically the required note on every write. |
| 6 | NumberField | A percent stepper. It is the rollout % control. |
| 7 | Select | A closed, short list of options. Built. |
| 8 | Combobox | A searchable closed list. Use it to pick a flag key or a segment. |
| 9 | Checkbox | Multi-select within a form. Built. |
| 10 | Radio group | Single-select among a small set of named options. Built. |
| 11 | Switch | Deliberately not used for the on/off state of a flag in a list. See the note in the spec. A list row needs a heavier confirm path than a switch implies. |
| 12 | Slider | The drag surface of the rollout % control, paired with NumberField. |
| 13 | Field / Label / Description / Error / Fieldset / Form | The wrapper that every form control sits inside. It sets the spacing from label to control and from control to error. Built (Field and Form; Fieldset is not). |
| 14 | Tabs | Environment tabs, underline style. Use them inside a page, never to choose the environment itself. |
| 15 | Menu | Row actions (the "⋯" menu of a flag row). QN-11a-ii needed it first, for the single destructive item of the member row, "Remove member…". The visual reference is Paper P05-0 (T3, menu open). The popup padding is 6px and the item is 30px high. The destructive text is --color-negative with the --color-negative-subtle highlight. Built. |
| 16 | ContextMenu | Right-click actions on a table row. |
| 17 | Popover | Non-modal detail panels, for example the quick-edit surface of a flag. |
| 18 | Tooltip | Short clarification on hover. |
| 19 | PreviewCard | A hover on a flag key, anywhere, shows its current value and rule summary. |
| 20 | Dialog | Modal confirms and small forms. QN-11a-ii needed it first, for the Invite dialog of T3. The visual reference is Paper OO1-0 (and AFS-0, the create-key dialog of T2, the same 440px shell). The title is --text-lg semibold. The corner close is 12px inset. The dialog has --shadow-popup and radius 10px. Built. |
| 21 | AlertDialog | Destructive confirms that require the person to type the key back, for example to delete a flag. The typed field is only for the actions with the highest blast radius. A one-click confirm (Title, Description, Cancel and the destructive button) is the same component. QN-11a-ii builds that shape first. The visual reference is Paper OBX-0 (remove member) and OHZ-0 (revoke invite), matching APW-0 (revoke key of T2). The dialog is 420px. The title is plain --color-fg text with no glyph. The small ! before the title on the canvas is a drawing convention only. It is ruled out everywhere that a destructive Button already spends the one negative colour of the screen (07d qn11a-ii-design-1(4), 07d qn11b-signoff-2). The reference for the typed shape is E8T-0 (delete workspace) and PVU-0 (delete environment). It is the same dialog plus one mono Field. The placeholder of the Field is the key to type back. The destructive button stays disabled until the typed value matches exactly. Built (both the one-click shape and the typed-key shape, the latter by T1/T7 of QN-11b). |
| 22 | Drawer / side sheet | Larger creation flows, for example the creation of a flag. |
| 23 | Toast | Write confirmations, always with an Undo action and the revision id. |
| 24 | Accordion / Collapsible | The rule stack, and the pre-registration detail block. |
| 25 | Avatar | Who wrote a revision, and who is assigned. Built. |
| 26 | Badge / Status text | Colored text and a dot, deliberately not a pill, for flag status and the six verdict states. |
| 27 | Meter | The rollout % bar. |
| 28 | Progress | Exposure collected toward the planned sample size. |
| 29 | Separator | Dividing rows and sections. Built. |
| 30 | ScrollArea | A styled scroll surface for long lists inside a fixed-height panel. Built. |
| 31 | Toolbar | Table filters above a list. |
| 32 | ToggleGroup | A segmented control for two to four views of one thing, for example table/chart or day/week. Never use it as the environment switcher. The environment switcher is the control of the sidebar (see Design rules). |
| 33 | NavigationMenu | The header of the marketing site. |
| 34 | Sidebar nav item | The primary app sidebar's row. Built. |
| 35 | Table | Every list view, built on TanStack Table with the row styling of Quoin. QN-11a-ii needed it first, for the member table of T3. The visual reference is Paper BIB-0. It has a 34px header row in --color-surface-2, 52px body rows, fixed-width columns (200 / 200 / 140 / the rest), and a fixed 32px slot at the end of the row for the "⋯" row actions. The slot is empty on the rows that have no actions (the row of the signed-in person). The lane then stays aligned. 7PB-0 (T1, environments) is the second reference. It has 48px rows, the same empty slot at the end of the row on the three seeded rows, and a plain-text count column ("2 active" / "none") where a Switch is not wanted. Built. |
| 36 | Command palette | ⌘K. Jump to any flag, experiment or settings page. |
| 37 | Empty state | "Nothing rolling out right now," and similar, for every list that can be empty. |
| 38 | Skeleton | Placeholders for rows and cards while data loads. |
| 39 | Banner | An inline, page-level notice (a stale-write conflict, a frozen-while-running notice). Built. |
| 40 | Verdict block | The headline sentence of the report, in Source Serif. It is one of the five signature elements. |
| 41 | Ruler | The interval drawn as a printer's line gauge. It is one of the five signature elements. |
| 42 | Locked block | The locked state of the pre-registration. It is one of the five signature elements. |
| 43 | Decision option card | A ship / rollback / inconclusive choice, shown with its projected number impact. |
| 44 | AI analysis block | The narration of the model for a report. It always cites the exact JSON field behind each number that it states. |
| 45 | Stamp | The rolled-back / shipped decision record. It is the one place where ALL CAPS appears in the whole system. |
| 46 | OTP Field | The six-digit sign-in code on /login/check-email (A2), built on otp-field of Base UI. It has six 52×56 slots in --font-mono 22px/28px. A paste of six digits fills all six slots. A wrong code paints the border of every filled slot --color-negative 1.5px. The sentence about attempts left appears under the button. An expired code empties the slots. The countdown becomes an active "Resend code" link. The visual reference is section 7 of Paper GET-0 (C-07), cloned from the A2 artboards BT8-0 / GDW-0 / BY6-0. docs/03-components.md §46 has the written anatomy (07d qn01-plan-8). Built. |
Reading this table
"Built" means that the component exists in packages/ui/src today and that a developer can import it from
@quoin/ui. All other components are specified, with a shape, a set of variants and an a11y contract. They have no
implementation to link to. When a team builds a component, its row here must gain a live <Example />, as the
nineteen above do. The row must change in the same commit as the component.