The design language in one file
Tokens, typography, colours, and every component spec in a downloadable markdown reference. The source of truth for how the system looks and behaves, copied verbatim from the repo on every build.
Overview
This design system is a themeable component library and documentation website. The atmosphere is built from roles, not colours: a page floor (--color-bg-page-primary) carrying containers (--color-bg-container-primary) that step through a neutral hierarchy, where the point is the contrast between floor and container, not its direction (a theme may set light containers on a deeper floor or the reverse), and a single action colour (--color-action-primary-bg) that carries all of the brand's voltage. The action colour is deliberately split between light and dark mode: one colour rarely clears 3:1 against both a light page and a near-black one while also carrying a 4.5:1 label, so each mode pairs its own fill with its own label, and the build holds every pairing to contrast in every theme.
This spec is theme-agnostic. The library ships several complete themes (see Preset themes below), and every statement here is meant to stay true under all of them. Rules are therefore written in token roles: "the action colour", "the page floor", "the primary family", never the colour or face one theme gives them. Two looks have a special standing, and neither is named here because each has one authoritative home: the base theme is what the raw token files in src/tokens/ carry, which a package consumer gets with no data-brand attribute (DEFAULT_THEME_LABEL in website/src/lib/theme/presets.ts names it), and the served theme is the data-brand the website's root layout renders on <html>. Colour values are never stated in this document: /foundations/colour-mode lists every colour token with a chip that paints the live value under whichever theme is applied, beside the base theme's wiring, and /foundations/themes shows each theme whole, with a specimen page per theme beside its generated DESIGN.md; the same values are data in rift-ds/tokens/values and in the DTCG files under /schema/tokens/ (see /docs/schemas). Where a dimension appears — a px radius, a ms duration, a font weight, a type size — it is the base theme's position, given for orientation, and the density, type, motion and radius levers can move it per theme. "Theme" in this document means a preset; light and dark are modes of every theme.
The type system runs one face per family role — a heading role and a body role, both resolving to --font-family-primary unless a theme splits them — set at weight 300 (display/hero) → 600 (headings) → 500/400 (body/UI) in the base theme, with one site-chrome exception — the header wordmark sits at 700, one step above title-body's 600, so the name reads heavier than the nav pills beside it (the Site header pattern owns the detail) — and one library exception, the iOS and Android variants of BottomNav and TopAppBar, which use each platform's own face so a mock reads as the real system chrome (the BottomNav spec owns it). Monospace appears only in sanctioned code contexts through --font-family-code. Which faces fill the roles is a theme's choice, and so is whether they are one face or two: the scale chains through --font-family-heading for the display and heading tiers and --font-family-body for the body tiers, both resolving to --font-family-primary by default, so a theme or a consumer splits heading and body faces by overriding the roles (several presets do; see Font Family below).
The three-tier token architecture is the defining structural rule:
- Primitives (
--primitive-*) — raw hex/px values. Source of truth. Never used directly in components. - Semantic tokens (
--color-*,--radius-*,--gap-*,--padding-*,--border-*,--font-*,--motion-*,--icon-size-*,--control-height-*,--shadow-*,--layer-*—CATEGORY_PREFIXESinscripts/generate-token-registry.mjsis the authoritative list) — usage-intent variables consumed by components. Always use these. - Component CSS classes (
.ds-button,.ds-badge, etc.) — per-component scope, referencing semantic tokens.
The system is light/dark-first: every semantic color token has a light-mode value and a dark-mode override. The switch is driven by data-theme="dark" on the root element. Status border keys stay perceptually stable across modes; surfaces and text invert.
Key Characteristics:
- Contrast between the page floor (
--color-bg-page-primary) and the primary container (--color-bg-container-primary) is what separates a surface from the page. The direction is the theme's call: containers can sit lighter than the floor or deeper than it, and the two modes can differ. Primary text (--color-text-primary) sits at the far end of the neutral ramp from the floor in both modes. - One action colour (
--color-action-primary-bg). Used exclusively on primary CTA buttons, focus rings, active input borders, the checked or on state of a form control (Checkbox, Slider's fill, ToggleSwitch, the selected-option check in a menu), and the selected item of a mutually exclusive set (the action selection convention: SegmentedControl's active segment, Pagination's current page, Stepper's active step, the site header's current-section pill), and the hover and focus signal of a surface that is itself a control (an expandable BentoTile's border), with one sanctioned data-viz exception: the action colour leads the chart series palette. Never decorative elsewhere. - One face per family role; the base theme uses one face for both roles, and a theme may split them. In the base theme: weight 300 for Mega/Display (marketing), 600 for headings, 500/400 for body and UI labels; 700 appears once, on the site header's wordmark. Tier weights are the theme's call, the same way the faces are: a theme may set the display and heading tiers to any weight, including the same one, and shift the body tier as a unit.
- Container hierarchy as depth signal — standard containers carry no drop shadows. Depth is conveyed by stepping through
--color-bg-container-primary→secondary→tertiary; the only shadows are the--shadow-floating/--shadow-modaltokens on floating surfaces,--shadow-table-pinnedon a pinned table column's scrolling edge, the interactive-card hover lift, and the iOS glass of the mobile navigation components, which carries the platform's own shadow. - Five semantic status variants running through every feedback component:
info,positive,warning,error,neutral. - Border radius is hierarchical and assigned per element type:
--radius-100for badges,--radius-300for inputs and standard containers,--radius-600for the sanctioned soft-container set the Border Radius Scale table below enumerates (navigation tiles, chat surfaces, Panel, the floating sidebar, AppLayout's inset surface),--radius-pillfor buttons. A theme moves the values; the assignments hold. - Material Symbols Rounded as the bundled icon set (a library choice, not a structural limit — see the icon style hooks and the node-accepting icon props), on a four-step size scale —
--icon-size-500(20px) /600(24px, default) /800(32px) /1200(48px). Optical size tracks the step automatically. - One GPU-rendered surface: ShaderField, an ambient field of soft light sources that sample the colour tokens at runtime, so it re-themes with everything else. It is scenery rather than interface, it reports how it resolved so a caller can keep a fallback painted, and its
prefers-reduced-motioncheck lives in JavaScript, where the CSS guard cannot reach (Carousel's autoplay gate makes the same move). - Accessibility-first: ARIA roles, semantic HTML, and keyboard navigation in every interactive component.
Preset themes
The playground's saved looks are shippable build artifacts, not demos. THEME_PRESETS in website/src/lib/theme/presets.ts is the registry of complete themes — every lever holds a position, never inherits one: the action brand (with an optional theme-dependent brandDark), neutral tint, radius scale and pill switch, spacing density, typeScale, motionScale, the elevation variant, the three tier weights (displayWeight, headingWeight, and bodyWeight, which shifts the body tier rather than setting it), a tracking offset, the icon font's iconWeight and iconFill, the body and heading typefaces, the ambient accent sextet (accents), the optional background-field colours (ambient) and AI gradient shape (aiGradient), hand-tuned ramp re-keys (advanced), and per-preset extras — colour corrections with dark-only variants where the derived ramp's shape inverts, but any token is fair game (Forest and Zest step Sub Display a notch under their display tier). The ambient accent sextet is part of every theme's declaration: the six --color-core-accent-* roles ship as hexes per preset and drive the background blobs and chart series 2–7 together. One pure composer, presetOverrides(preset, theme), turns a preset into its exact override map, and the generated stylesheets are written from it; the playground's live preview composes the same lever functions in a memo of its own that mirrors it. The neutral tint is the one lever whose primitives differ by theme: light mode's two surface steps, the container white and the page floor, take only a share of the tint (LIGHT_SURFACE_TINT_RATIO in website/src/lib/theme/theme-overrides.ts), because a full-strength tint greys a light floor far enough to cost body copy its contrast, while dark mode reads the same steps as ink and keeps the tint whole. A tinted preset's two lightest neutrals therefore differ between its light and dark blocks, and a hand-written tint override needs both.
The base sheet. tokens/base.css is optional and imported once after tokens.css, which does not pull it in: the reset every component is measured against (border-box sizing, no margin or padding an element did not ask for), color-scheme per mode so native controls and scrollbars follow dark mode, the body's font, colour and background, and headings and paragraphs on the type tokens. Every rule sits inside :where(), so it carries no specificity and a consumer's own class always wins. It is the sheet the documentation site has always run under, published so a consumer's first screen measures as the site does.
scripts/generate-preset-stylesheets.mjs writes each preset to src/tokens/presets/<id>.css — an html[data-brand="<id>"] block carrying the light declarations and an html[data-brand="<id>"][data-theme="dark"] block carrying the dark ones, emitted even when identical so the cascade is self-evident — plus a presets.css aggregate that only imports them all. The files ship in the npm package under the tokens/presets/ subpath. The contract: import tokens/presets/presets.css after tokens/tokens.css, set one data-brand attribute on <html>, and the whole app rethemes end to end — light and dark included, zero runtime JavaScript. Specificity does the work: html[data-brand] outranks the token files' :root and [data-theme] blocks, and the preset's dark block outranks its light one. The documentation website loads the bundle from its root layout, so any page rethemes the moment the attribute is set.
Two build gates guard the surface. scripts/validate-preset-stylesheets.mjs regenerates in memory and byte-compares every file, holds the folder to THEME_PRESETS in both directions, and parses one preset's CSS back into declarations to prove it equals the composer's output. scripts/validate-theme-presets.mjs is the completeness gate — the "one attribute, full theme" guarantee: every override name must resolve to a real token, a primitive, or one of the icon font's axis hooks, the action family must be overridden or provably intended-default (the brand equals DEFAULT_BRAND, which the action lever short-circuits to the base token files), every action fill must hold contrast with the label or icon components draw on it, in every state (resting, hover and pressed; labels at WCAG AA 4.5:1, icons and strokes at 3:1) and in both themes, for every preset and for the base token files (the script's ACTION_PAIRINGS table is authoritative, and every action fill token must appear in it; a cell that deliberately cannot hold would be pinned in SANCTIONED_AA_GAPS with its ratio and reason, never silently passed — the map is empty today, every cell holds), and every feel lever must be present on every preset (the script's lever list is authoritative) — as must all six ambient accent roles, in both themes, with no intended-default exemption.
Colors
Token Tiers
Never reference --primitive-* tokens inside components. Always use the semantic layer (--color-*). The chain is enforced in the other direction too: every semantic colour token's value must be a var(--primitive-*) reference — never a raw hex/rgba literal — so that overriding a primitive re-themes everything built on it (scripts/validate-token-references.mjs fails the build otherwise). That chain is also what makes the themes possible: a preset re-keys the primitive ramps and re-points semantic roles, and every component follows. This section therefore describes what each colour role is for and how it relates to its neighbours, never what colour it is; where a role's wiring is described in primitive steps, that is the base token files' wiring.
The primitive colour scales share one shape. The neutral scale and the seven chromatic ramps (red, orange, yellow, green, teal, blue, purple) all run 00–11, light to dark — relative luminance decreases strictly down every chromatic ramp, build-enforced by scripts/validate-theme-mirrors.mjs. Step 07 is each chromatic ramp's hand-drawn key colour; the 00 and 11 end caps are generated tint/shade extensions (the blend method and factors are documented above the ramps in tokens-primitives.css). Translucent neutral variants follow the -aNN grammar — NN is the alpha × 100, so --primitive-neutral-09-a80 is neutral-09 at 80% opacity — and the suffix is held to the rgba() value's actual alpha by scripts/validate-token-references.mjs.
Action / Brand
The action roles are the one token family that splits per mode by design (see Overview): each mode pairs its own primary fill with its own label (the neutral roles stay neutral-on-neutral in each mode), and by default hover/active walk away from the page — deepening in light, brightening in dark. The rule underneath is that state fills walk away from their label, so every state keeps the label's contrast. For a deep light-mode key and a light dark-mode key the two rules agree. For other keys they can differ: a mid-tone key in dark mode carries a light label, so its states deepen, and a light key in light mode carries a dark label, so its states lighten. The action lever (actionPointerOverrides in website/src/lib/theme/theme-overrides.ts) applies the label rule, and scripts/validate-theme-presets.mjs checks every state of every preset against it.
- Primary bg (
--color-action-primary-bg): The action colour. Every primary CTA button fill, focus rings, active input borders, and selected-state fills under the action selection convention. The most-recognized brand color. - Primary bg hover (
--color-action-primary-bg-hover): Hover shift on primary buttons. - Primary bg active (
--color-action-primary-bg-active): Press/active shift on primary buttons. - Primary text (
--color-action-primary-text): Text/icon color on the primary fill — a tint of the action key on a deep fill, a shade of it on a light one. - Primary text active (
--color-action-primary-text-active): Text on hovered/active primary button. - Icon active (
--color-action-icon-active— an alias of--color-action-primary-text-activein both themes): The icon on a hovered/active primary fill, including the outlined CircularButton's. It is an alias rather than its own step because it sits on exactly the fills the active label does, so a theme that repaints those fills repaints labels and icons together and the two can never disagree. - Primary border (
--color-action-primary-border): Outline on secondary (outlined) Buttons and CircularButtons. - Secondary border (
--color-action-primary-border-secondary): Brighter border step — the completed-step border and connector in Stepper. - Tertiary border (
--color-action-primary-border-tertiary): Hover ring on RadioButton. - Tertiary text (
--color-action-primary-text-tertiary): Action-coloured text for tertiary/ghost button labels. - Passive bg (
--color-action-passive-bg): Near-transparent ghost button fill. - Passive bg hover (
--color-action-passive-bg-hover): Translucent neutral hover on ghost/tertiary buttons. - Passive text (
--color-action-passive-text): Ghost button label color. - Neutral bg (
--color-action-neutral-bg): Solid neutral fill for primary-emphasis buttons in theneutraltone — unlike passive, the background is always shown at rest. - Neutral bg hover (
--color-action-neutral-bg-hover): Hover shift on neutral buttons — deepening in light, brightening in dark. - Neutral bg active (
--color-action-neutral-bg-active): Press/active shift on neutral buttons. - Neutral text (
--color-action-neutral-text): Text/icon color on the neutral fill. - Neutral border (
--color-action-neutral-border): The outline of secondary-emphasis buttons in theneutraltone — the neutral counterpart of--color-action-primary-border, drawn at the same step as--color-bg-container-borderso it matches a SegmentedControl beside it. - Destructive bg (
--color-action-destructive-bg): The danger fill — what outlined destructive controls take on hover and press, and filled destructive buttons wear at rest. Keyed to the red ramp the error status uses, never to an accent, so danger reads as danger in every theme (a preset whose action colour sits on the red ramp rebases it, and danger follows that red like its error status does). - Destructive bg hover / active (
--color-action-destructive-bg-hover,--color-action-destructive-bg-active): The filled destructive button's hover and press steps, walking away from the page like the primary family. - Destructive text (
--color-action-destructive-text): Label and icon on the destructive fill, AA on every step in every theme. - Destructive text secondary (
--color-action-destructive-text-secondary): Label and icon of a destructive control on the page — outlined destructive buttons, destructive menu and command rows — one step further from the page than the fill, so it holds AA on the page and on the error tint behind a highlighted menu row. - Destructive border (
--color-action-destructive-border): The outline of destructive controls, on the fill's step.
Surfaces
- Page primary (
--color-bg-page-primary): The floor of every screen. - Container primary (
--color-bg-container-primary): First elevation above page — sidebars, section bands, card fills. What makes it read as a surface is its contrast with the floor, not which of the two is lighter: a theme may set it lighter or deeper than the page, and the two modes may go opposite ways. - Container secondary (
--color-bg-container-secondary): Second elevation — nested containers, divider fills. - Container tertiary (
--color-bg-container-tertiary): Third elevation — pressed states, control tracks (Slider, ProgressBar, Meter, Spinner, Gauge), deepest nesting. - Container border (
--color-bg-container-border): Hairline borders on containers. - Container inverse (
--color-bg-container-inverse): High-contrast inverted surface — ImageCompare's handle label, the calendar and Gantt "today" markers. Always paired with--color-text-on-inverse. - Glass (
--color-bg-glass): The floating-surface fill — deliberately translucent in both modes so the page's colours reach the surface. Paired withbackdrop-filter: blur(24px), which is what keeps content readable through it; the blur radius is a documented constant, not a token, and MegaMenu raises it to 32px because it lands on display type (its spec says why). Every floating surface takes it. - App shell (
--color-bg-app-shell): The inset layout's back plane: the colour behind a flush rail (and its side panel) and around the inset content surface. It sits a step off the surface toward the middle of the ramp, so the surface reads forward of it. - App surface (
--color-bg-app-surface): The inset layout's content plane, the far end of the neutral ramp in each mode (the brightest step in light, the deepest in dark), so the page's content sits furthest forward. A docked chat panel in the inset layout takes it too. Cards on it keep their own container fill, held off it by a hairline. - Divider (
--color-divider): Horizontal/vertical rule between sections.
Table surfaces
Every table reads one family, so a markdown table in a doc page or a chat reply and a data table in a product UI are drawn alike: the library Table (plain and bordered), DataTable, Prose's markdown tables and the site's blueprints tables. A table is a surface of its own and stands off what it sits on, which takes a different move per mode: in light it is brighter than the page, the card fill with a header a touch off it; in dark it is a little darker than its surface, the page floor with the header one container step up, so on a panel or a card it reads as an inset. Each role aliases a container or page role, so a theme that retunes the ramp retunes its tables, and scripts/validate-table-styles.mjs fails the build when any other stylesheet colours a table part or a drawing file colours one outside the family.
- Body (
--color-table-body-bg): light--color-bg-container-primary, dark--color-bg-page-primary. - Header (
--color-table-header-bg): a half step off the body in both modes, between the container steps either side of it: light--primitive-neutral-02-a50, dark--primitive-neutral-08-a50(the scale's 02 and 08 greys at half strength, which every preset re-tints with its neutral). - Row hover (
--color-table-row-hover-bg): light--color-bg-container-secondary, dark--color-bg-container-primary: lighter than the header, so a hovered row never reads as a second header. - Row stripe (
--color-table-row-stripe-bg): light--color-bg-container-secondary, dark--color-bg-container-primary-semi. - Row selected (
--color-table-row-selected-bg): light--color-bg-container-tertiary, dark--color-action-passive-bg-hover; held while the checkbox is on. - Divider (
--color-table-divider→--color-divider): The line between rows and under the header. - Border (
--color-table-border→--color-divider): The bordered table's outline.
Chat surfaces
Chat surfaces get their own colour roles so they can be re-themed without touching the container ramp. The four bubble roles currently alias the container-secondary / text-primary pair — deliberately identical today, separable later; the context fill is its own tint.
- Sent bg (
--color-chat-bubble-sent-bg→--color-bg-container-secondary): Fill of the person's outgoing bubble. - Sent text (
--color-chat-bubble-sent-text→--color-text-primary): Text inside the sent bubble. - Received bg (
--color-chat-bubble-received-bg→--color-bg-container-secondary): Fill of a received bubble, when the agent's turn renders as a bubble at all. - Received text (
--color-chat-bubble-received-text→--color-text-primary): Text inside the received bubble. - Context bg (
--color-chat-context-bg→--primitive-neutral-01light /--primitive-neutral-09dark): Fill of the Composer's context note — the quietest tint per theme, the nearest neutral to each shell that still separates from it. Its own role rather than a container alias because it deliberately sits off the container ramp. - Card bg (
--color-chat-card-bg→--primitive-neutral-00light /--primitive-neutral-10dark): The fill every chat card sits on (the decision, record, content and disclosure cards and QuestionCard's rows): the crisp end of the neutral ramp in each theme, so the cards read sharp against the reply and the bubbles. Off the container ramp on purpose, because the container fill is translucent in dark mode.
ChatMessage consumes only the four bubble roles above — never the container tokens directly. The action colour is never a bubble fill; it keeps its CTA meaning.
Calendar surfaces
EventCalendar's day cells and event pills get their own colour roles, for the same reason chat surfaces do — tunable without touching the container ramp. The family exists because the dark container ramp's steps are too far apart for calendar density: a cell field on the secondary container step glares against the near-black calendar container, and a hover that lands between the cell and pill fills swallows both. Cells rest one quiet step off the container and step one gentle notch away from the pill fill on hover — lighter in dark, darker in light; a hovered cell keeps its hover while the pointer is on a pill inside it, so the pill's own hover fill and hover-only border ring are what separate it.
- Cell bg (
--color-calendar-cell-bg—--primitive-neutral-01light /--primitive-neutral-08-a80dark): Resting fill of an in-month day cell. - Cell bg hover (
--color-calendar-cell-bg-hover—--primitive-neutral-02-a80light /--primitive-neutral-07-a80dark): The clickable cell's hover — one gentle step lighter in dark, one gentle step darker in light, always away from the pill fill. - Event bg (
--color-calendar-event-bg→--color-bg-container-primary): Resting fill of an event pill — the calendar container's own surface, so pills read as windows down to it. - Event bg hover (
--color-calendar-event-bg-hover→--color-calendar-cell-bg): A hovered pill takes the resting cell tint — always one step off the hovered cell it sits on (darker than that cell in dark, lighter in light). The "+N more" overflow row's hover shares it, so every interactive row inside a cell separates the same way. - Event border (
--color-calendar-event-border—--primitive-neutral-04light /--primitive-neutral-06dark): The hover-only hairline ring on the pill under the pointer — an obvious mid-grey that reads against both cell states, while resting pills carry a transparent border so the ring appears with no layout shift. Its own role because the container border colour sinks into the dark cell fill.
Overlay & Controls
- Scrim (
--color-scrim): Modal backdrop behind Dialog, AlertDialog, Drawer and CommandPalette. Darker in dark mode so the modal still separates from the near-black floor. - Control thumb (
--color-control-thumb— chains to--color-action-primary-textin both modes): The circular thumb inside toggle switches (SwitchIndicator, the track ToggleSwitch, InspectorToggleSwitch and SelectionCard's toggle mode all draw). Riding the on-action ink means every theme's AA-held action pairing keeps the thumb legible on its track — a light dark-mode action fill (Smoke, Volt) gets a dark thumb automatically, where a fixed near-white value vanished into it.
Text
- Primary (
--color-text-primary): Running text and primary content — the far end of the neutral ramp from the page floor. Headlines read the display and heading roles below, which alias it, so they match it unless a theme tones them. - Secondary (
--color-text-secondary): Emphasized body, card subheadings. - Tertiary (
--color-text-tertiary): Labels, helper text, captions. - Inverse (
--color-text-inverse): De-emphasized secondary labels; inverts in dark mode. - On inverse (
--color-text-on-inverse): Text sitting on--color-bg-container-inversesurfaces (ImageCompare's handle label, the "today" markers). - Display (
--color-text-display→--color-text-primary): Text set in a display-tier style (Mega, Display 1 and 2, Sub Display). Aliases the primary text in the base look; a theme can give the tier a tone of its own, which is how a headline takes a colour the copy under it does not. - Brand mark (
--color-brand-mark→--color-action-primary-bg,--color-brand-mark-end→--color-action-primary-bg-active): The two stops of the logo mark's gradient, and of every mock product's mark. They alias the action fill so the logo wears the action colour by default, and exist as their own roles so a theme can colour the logo apart from the action (the action colour stays reserved for actions). The end stop follows the first by default; a theme can give it a second colour, so the mark stays a two-tone gradient. A toned mark must hold 3:1 against the page in both modes. - Product mark (
--color-product-mark→--color-core-ui-primary): The disc a template's fictional product wears (ProductMark). It follows the core UI colour by default; a theme's logo tone colours it together with the brand mark, so one lever moves every logo on the page. - Heading (
--color-text-heading→--color-text-primary): Text set in a heading-tier style (Headings 1 to 3). The same arrangement one tier down. Title Body is a label tier for colour and stays on the primary text. A tier tone must hold contrast against the page and the container fill in both modes (3:1 for display, which is only ever large, 4.5:1 for headings), and the theme presets gate it.
Icons
- Primary (
--color-icon-primary): Default icon fill — tertiary buttons, input icons, nav icons. - Secondary (
--color-icon-secondary): De-emphasized icons. - Background (
--color-icon-bg): The neutral disc behind an icon in the icon tile pattern. A fill for a small mark, not a surface, so it sits outside the container ramp. - Label background (
--color-label-bg): The neutral fill behind a small data label, LegendTile's tile. It moves apart from--color-icon-bgonly if a theme wants labels and icon discs told apart.
Status (backgrounds and text invert between modes; borders hold their key)
Each of the five variants (positive, warning, error, info, neutral) carries the same steps, drawn from one ramp per variant (green, orange, red and blue in the base wiring; neutral from the neutral ramp):
| Step | Light mode | Dark mode |
|---|---|---|
--color-status-<role>-bg | A pale tint of the ramp | A deep shade of the ramp |
--color-status-<role>-border | The ramp's key colour | The same key (neutral steps one notch lighter) |
--color-status-<role>-text | A deep shade, readable on the pale tint | A pale tint, readable on the deep shade |
A theme that re-keys the chromatic ramps re-keys its status colours with them; what holds is the role-to-ramp wiring and the contrast between each background and its text.
Each role also carries an icon step (--color-status-<role>-icon): the glyph colour for status indicators sitting on plain container surfaces (AgentPlan's step indicators, ToolCall's state glyph, SourceTrail's rows). The text step is tuned for its tinted status background and washes out on a bare container — in dark mode the near-white text tints all read as grey. Light mode uses the deep inks, around step 08 of each ramp, and dark mode brightens to mid-ramp steps that hold at least 3:1 on the dark container floor.
Trend (mode-split — the "number went up / went down" role)
- Up:
--color-trend-up— in the base wiring's light mode the brightest green-ramp step that holds AA on the primary container, the ramp's vivid key in dark - Down:
--color-trend-down— in the base wiring's light mode the brightest red-ramp step that holds AA on the primary container, the ramp's vivid key in dark
Stat deltas and any table mover colour through these, never through the accents directly: on light surfaces the vivid keys fall below readable contrast, so the light half steps down the ramp until it holds, while the dark half keeps the vivid keys, which read well on dark fills. The role exists so that "went up" has one colour everywhere it appears.
File type (the "what kind of document" role)
- PDF:
--color-file-pdf, red - Spreadsheet:
--color-file-spreadsheet, green (a CSV shares it) - Document:
--color-file-document, blue - Presentation:
--color-file-presentation, yellow
The type mark on an attachment colours through these and nothing else does. They sit beside the status and trend sets rather than inside them: status says what state something is in, the chart series says which data series it is, and a file colour says what format a document is. One role per well-known format is the whole family; every other file wears a neutral extension badge, so colour stays a signal for the formats a reader recognises at a glance.
This is the one colour family a theme does not own. Each role points at a fixed file-format primitive (--primitive-file-*), the hue that format wears everywhere else a person meets it, and it holds the same value in every preset and in both modes, because recognition is the entire job and a re-tinted PDF red would stop being recognised. Two rules keep that safe. The colours only ever fill the mark's silhouette, a graphic beside a file name that already states the extension; and nothing is ever read against them, because the mark's lettering is drawn in a text colour. A consumer who wants house colours overrides the four primitives.
Core Accents (data-viz, decorative — not for semantic status)
Six named slots: --color-core-accent-coral, --color-core-accent-amber, --color-core-accent-gold, --color-core-accent-mint, --color-core-accent-cobalt, --color-core-accent-violet. The names are slot names, not promises of a hue: every theme declares its own sextet (a monochrome theme fills all six with greys), and the sextet drives chart series 2–7 and EventCalendar's event accents together, and is what the ambient background follows by default. A theme may re-point its chart series apart from the sextet in its extras: Smoke keeps grey accents and draws its charts from its ink-wash ramps.
Ambient (the background shader's blob colours)
Six numbered slots, --color-ambient-1 to --color-ambient-6, one per coloured blob in the ambient background. In the base wiring each points at a core accent (gold, mint, violet, cobalt, coral, amber, in that order), so the field and the chart palette share their colours. They are separate tokens so a theme can tune the field alone: a preset that declares ambient in THEME_PRESETS states all six. Most of the shipped presets that do keep four on their accents and move the coral and amber slots to lighter tones of their key colour, so the field loses its warm patch while the chart series keep the contrast between hues that data needs; a look whose field is its own palette states it outright, as Marquee's deep blue-green and Signal's two tones do. The ambient background reads them, and so does BentoTile's shader wash by default, so a tile's wash and the page's field are the same light.
AI (reserved for AI entry points and surfaces)
The three-stop AI gradient is the system's "a model answers here" signal, the AI counterpart to the action colour: ordinary actions keep the flat --color-action-primary-bg, AI affordances carry the gradient, and the gradient is never used for an ordinary action's job — or decoratively. Inside shared list chrome the reservation changes form rather than lapsing: an AI affordance in a uniform list never restyles its own row (a one-off row treatment would break the list before it signalled anything), but it may wear the gradient as a passive mark — a non-interactive ringed badge at the row's trailing edge, signalling the destination without touching the row's shape. The site palette's ask-the-chat row is the reference: its label stays the visitor's own query in the list's plain face, and the AI-ring chip rides the CommandPalette trailing slot (see that spec for the slot's contract). Always used as gradient stops (AiButton's ring and glow are the reference), never as flat fills for ordinary chrome. One sanctioned departure: the landing hero's rotating subtitle changes its line behind a passing band of the three stops. Nothing there is interactive and no model answers, so it is the one place the gradient marks a subject (the page's own AI claim) rather than an affordance; it is not a precedent for decorating other headings. A second sanctioned departure: the playground's theme-source dropzone, while it waits for a file, passes a band of the three stops across its headline and one sweep of the ring round its edge every six seconds, resting as an ordinary card between passes. It marks the tool's headline feature, exists only until a file is loaded, and has no passes at all under reduced motion; it is not a precedent for decorating other cards.
| Token | Base wiring | Under a preset |
|---|---|---|
--color-ai-gradient-start | red-05, both modes | A tonal preset: the action key's ramp, four steps lighter than the key. A fade theme: the key's ramp, two steps toward the page. Otherwise the same step, on the red ramp as the preset re-keys it |
--color-ai-gradient-mid | blue-05, both modes | A tonal or fade theme: the action key's own step. Otherwise the same step, on the blue ramp as the preset re-keys it |
--color-ai-gradient-end | teal-07, both modes — two steps deeper than the other stops | A tonal preset: the action key's ramp, two steps deeper than the key. A fade theme: the key's ramp, four steps toward the page. Otherwise the same ramp split by mode (step 07 in light and 08 in dark, following where the key sits) |
A fade theme is one whose source states no gradient of its own: the playground's imports, from a DESIGN.md or a photo. Its ring is the action colour fading toward the page, lighter tints in light mode and deeper shades in dark, so it dissolves into the surface rather than carrying hues the source never named.
| --color-ai-button-bg | neutral-00 light / true-black dark | Unchanged |
A preset chooses between two shapes (aiGradient in THEME_PRESETS). The base look and any preset that leaves the field unset keep the red and blue stops, which suit a blue or teal key, unless its extras state the three stops directly: Signal does, so its ring crosses from blue to its orange key. A tonal preset draws all three stops from its action key's ramp, so the ring is the key colour at three depths and carries no hue the theme does not own; a key too near the light end of its ramp hands the missing light steps to the deep side, so the spread holds.
In the base shape, the start and mid stops are bright enough to stay luminous on dark surfaces in both modes, while deeper inks would read as a heavy dark border on a light floor. The end stop follows the action colour, so a preset that moves the action key moves it too, and it sits deeper than the other two so the gradient ends on the same weight the action colour carries.
The family carries one solid fill alongside the stops: --color-ai-button-bg, the AI button's field. It is the theme's extreme surface — brighter than the light page floor, deeper than the dark one — so the AI entry point punches out of whatever it floats over rather than frosting into it. Reserved with the rest of the family: it fills AiButton's pill and the passive marks that wear its face (the site palette's ask chip), nothing else.
Pixel ink (mode-split — the "which generated character" role)
- Ink 1 to Ink 6:
--color-pixel-ink-1→-6— one ink per chromatic ramp outside the brand's (red, orange, yellow, green, blue, purple), each a deep step of its ramp in light mode and a high step in dark
A PixelAvatar is drawn in one of these, picked by its name. They are a set of their own rather than the chart series because the job is different: a series colour fills a large mark on a chart and is tuned as a mid-tone that works on both grounds, while a sprite is a few small solid shapes on the page and needs a text-like contrast, so each ink splits by mode. Yellow and green sit a step deeper than their neighbours in light mode, because those ramps stay bright for longer. PromptSuggestions' card covers borrow the same inks for their tint, cycled by position. The brand's own ramp is left out on purpose: a theme re-points it, so an ink there would become a second copy of whichever ink the theme's colour resembles, and two neighbours in a list would match. Nothing else uses them: they identify, they never signal a state.
Count badge (mode-split — the "how many" mark)
- Fill and Figures:
--color-count-badge-bgand--color-count-badge-text— the error ramp at a step its figures read on, deep in light mode and bright in dark, the figures in the page's ink
The solid pill a count rides on an icon's corner, and the dot that stands for one: TableToolbar's filter, sort and group counts, BottomNav's tab badge, AppSidebar's and SidebarPanel's item counts and TopAppBar's dot share it, so a count reads the same everywhere and is seen at a glance. It is a count, not an error: a component that means a failure uses the status tokens.
Avatar (mode-split — the "whose initials" role)
- Fill 1 to Fill 6:
--color-avatar-bg-1→-6, with Initials 1 to 6:--color-avatar-text-1→-6— the pixel inks' six ramps in their order, a pale step under a deep one in light mode and the reverse in dark
An initials Avatar takes one pair, picked by its name. The fill is large and the initials are text, so each pair is a fill and an ink together, held to a text contrast in both modes, and a stack of people reads apart rather than as one grey run. The brand's ramp is left out for the pixel inks' reason. They identify, they never signal a state.
Typography
Forced colors (Windows high contrast)
Forced colors swap every colour for the user's own and flatten every fill to the page, so a state carried by a fill alone would vanish without an error. tokens-forced-colors.css (imported last by tokens.css) re-points the roles that carry a state at system colours, the one fill the mode keeps: the action family at Highlight and HighlightText, so a checked box, a switched-on toggle, the selected segment and the primary button still stand apart, and the count badge at Mark and MarkText. It adds no token, only new values for existing ones, with a selector strong enough to win over every theme and preset. The browser drops shadows and keeps borders, so a floating surface keeps the hairline it already draws, and focus rings are outlines, which the mode recolours and keeps. The page-level accessibility check renders the table bench in this mode and fails when any of those states draws no fill of its own, an open panel has no edge, or a focused control has no ring.
Font Family
Every family role resolves to one face, --font-family-primary, unless a theme splits them. Which face that is belongs to the theme: the base token files set Nunito Sans, and each preset declares its own body and heading faces. No monospace face is bundled — code contexts (CodeBlock, CodeDiff, Prose code) use the system mono stack codified as --font-family-code, in every theme.
Code is the one place the system leaves the primary face, through --font-family-code, and it rides the paragraph sizes rather than a size of its own. A monospace face draws its lowercase taller and wider than a text face at the same size, so every code surface (CodeBlock, CodeDiff, Prose's code) sets font-size-adjust to --font-code-aspect: the face is scaled until its lowercase stands as tall as the body face's, and code reads level with the text around it. The maps' readouts set the code face as display type and keep its own metrics.
Whatever face a theme picks needs the weights that theme's tiers ask for. The base theme asks for 300 through 600 and a light weight that still reads at display size; a theme whose face has no light cut sets its display tier heavier instead (Velvet's display serif starts at regular).
Family roles. No type-scale step references --font-family-primary directly. The scale chains through two role aliases in tokens-typography.css, split along the tier boundary the Hierarchy table below draws:
| Role token | Resolves to | Feeds |
|---|---|---|
--font-family-heading | var(--font-family-primary) | The display and heading tiers — Mega 1/2, Display 1/2, Sub Display, Heading 1–3 |
--font-family-body | var(--font-family-primary) | The body tiers — Title Body, Paragraph Em, Paragraph, Paragraph SM (+ Em), Overline, Caption |
Both roles default to the primary family, which is what keeps the three levers coherent: override --font-family-primary and the whole system re-fonts as one face (the base theme's position); override --font-family-heading alone and headings take a display face while running text keeps the primary; override both roles and the primary token stops mattering. Title Body sits on the body role deliberately — it is a bold label inside working UI (card titles, table column heads), and a consumer pairing a serif display face with a sans text face wants that label in the text face, not the serif.
Sourcing. The base theme's face is deliberately not bundled with the npm package. The website self-hosts it via next/font/google (fetched from Google Fonts at build time and served first-party), Storybook loads it with a Google Fonts <link>, and package consumers load it however their stack prefers — then swap the whole system to another face by overriding --font-family-primary, or split faces through the family roles above. The presets' faces are the opposite case: they ship self-hosted inside the package, declared by each preset's generated stylesheet, so a theme arrives complete with its type. Material Symbols Rounded, by contrast, ships inside the package as a self-hosted woff2, so icons need no external setup.
Type scale ladders
Every style bundle's -size (and pixel -line-height) resolves through two shared ladders in tokens-typography.css, named by the same rule as the spacing scale (rank = px / 4 x 100): --font-size-300 (12px) up to --font-size-3300 (132px), and --font-line-height-400 (16px) up to --font-line-height-1100 (44px). This is the type-size unlock: override a ladder step and every tier that reads it rescales together, without touching the tier bundles. The display tiers' unitless leadings (0.92-1.15) are ratios, not lengths, and stay in their bundles.
Hierarchy
| Token prefix | Size | ≤768px | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|---|
--font-mega-1-* | 132px | 64px | 300 | 0.92 | +2% | Marketing hero — never in app UI |
--font-mega-2-* | 116px | 56px | 300 | 0.92 | +2% | Marketing hero — never in app UI |
--font-display-1-* | 96px | 48px | 300 | 1.05 | +2% | Large marketing section heads |
--font-display-2-* | 64px | 40px | 300 | 1.1 (1.15 ≤768px) | +1.5% | Marketing section heads |
--font-sub-display-* | 30px | 24px | 300 | 44px (36px ≤768px) | +1.5% | Sub-section heads on marketing pages |
--font-heading-1-* | 30px | — | 600 | 44px | +1.5% | Page-level h1 in app/docs |
--font-heading-2-* | 26px | — | 600 | 32px | +1.5% | Section headings in app/docs |
--font-heading-3-* | 22px | — | 600 | 28px | +1.5% | Sub-section headings in app/docs |
--font-title-body-* | 16px | — | 600 | 24px | −1% | Card titles, table column heads, bold labels |
--font-paragraph-emphasis-* | 16px | — | 500 | 24px | −1% | Emphasized body, button labels, input labels |
--font-paragraph-* | 16px | — | 400 | 24px | 0 | Default running text |
--font-paragraph-sm-emphasis-* | 14px | — | 500 | 20px | 0 | Compact button labels, badge text, form helpers (emphasized) |
--font-paragraph-sm-* | 14px | — | 400 | 20px | 0 | Secondary body, helper text |
--font-overline-* | 14px | — | 600 | 20px | +8% | Uppercase labels — eyebrows, map callout titles, code-language chips |
--font-caption-* | 12px | — | 400 | 16px | 0 | Footnotes and disclaimers — the floor of the scale |
The ≤768px column is not a separate token set: the same --font-*-size tokens re-resolve inside a single @media (max-width: 768px) block at the bottom of tokens-typography.css. Anything set in display-tier tokens collapses automatically on mobile; the heading and body tiers never step (marked —). The Size and Weight columns, like everything token-owned, are the base theme's positions — the presets' typeScale, tier-weight and tracking levers move them per theme.
Principles
- Mega and Display sizes (Mega 1/2, Display 1/2, Sub Display) are marketing-only — they belong on the documentation homepage and landing pages, never inside application UI or component pages. One sanctioned exception: the site chat's welcome ask line is set in
--font-sub-display-*, because the empty widget is a landing moment, not working UI — the pair reads as a small heading-weight greeting over a large display-weight invitation, the base theme's weight contrast at welcome scale. The exception ends where the conversation starts: nothing in a live thread uses the display tier. - Headings 1–3 use weight 600 and display styles weight 300 in the base theme, and never bold a display size within a theme — a theme sets its tier weights once, and the use site never overrides them. Weight is a per-preset position like size: the
displayWeightandheadingWeightlevers set their tiers (Forest sets its display tier to 600 to suit its heavier display face) and thebodyWeightlever shifts the body tier as a unit, but nothing inside a theme bolds a display token at the use site. - Display leading is unitless, and loosens as the size drops: 0.92 on Mega, 1.05 on Display 1, 1.1 on Display 2 (1.15 on mobile, where the same headline wraps to more lines). Set solid, a display line reads as a poster on one line and closes up on two — and these headlines wrap, because a page title is a sentence. The heading and body tiers state leading in px instead, because at those sizes the line box is a rhythm the whole page snaps to.
- Positive letter-spacing (+1.5–2%) applies to all display and heading sizes. Negative letter-spacing (−1%) applies to title-body and paragraph-em — this tightening is what makes UI labels feel crisp.
- Buttons use
--font-paragraph-emphasis-*(default) and--font-paragraph-sm-emphasis-*(compact). Weight 500 on interactive controls in the base theme, one step above the paragraph; a theme's body weight moves it with the tier. - Overline is the uppercase label face, and always pairs with
text-transform: uppercaseat the use site — the tokens set the metrics, never the casing. Its +8% tracking is the one positive letter-spacing outside the display and heading tiers, because capitals at label size need air to stay legible. A component may keep the metrics and swap the family (MapCallout sets its title in the code face). - Caption (12px) is the floor, and it is for text the reader can find but never has to read to use the screen: the site chat's disclaimer line, a footnote under a chart. Never on a control, never for helper text (that is Paragraph SM), never for running copy.
Page Layout & Heading Hierarchy
This section documents how to apply the typographic scale when building new pages in the documentation website. Following these rules ensures every page reads with the same visual hierarchy.
Heading Roles
| Level | Token | Size | Weight | Role |
|---|---|---|---|---|
Page title (h1) | --font-display-2-* | 64px | 300 | Route title — rendered once per page at the top, outside the markdown body |
Major section (h2) | --font-sub-display-* | 30px | 300 | Top-level content sections (e.g. Colors, Typography) |
Sub-section (h3) | --font-heading-3-* | 22px | 600 | Named groupings within a section (e.g. Token Tiers, Action / Brand) |
Minor heading (h4) | --font-title-body-* | 16px | 600 | Inline labels or additional sub-groups |
Weight Contrast
The base theme separates the major → minor transition by weight contrast, not size alone. That is the base theme's choice, not a rule of the system.
h2sits on the display tier andh3on the heading tier. In the base theme that is 30px / 300 (light) over 22px / 600 (bold): the weight jump makes the hierarchy unmistakable at a glance.- Tier weights belong to the theme, like the faces do. A theme may move them, invert them (Zest sets its display tier heavier than its headings), or set both tiers to one weight. A theme that closes the gap leans on size alone, so it should check that a heading still reads as a sub-item of the one above it.
- Within a tier, levels step by size and share a weight:
h1andh2are both display-tier,h3andh4both heading-weight. A theme sets a weight per tier, never per page. A preset may specialise a single style through itsextraOverrides(Forest and Zest step Sub Display down a notch from their display tier); nothing else departs from the tier. The body tier moves as a unit rather than to one weight: a theme sets the paragraph's weight, and emphasis, overline and caption shift by the same amount, so emphasis stays a step above the paragraph whatever the theme's body weight is.
Section Dividers
h2 elements carry a border-bottom: var(--border-025) solid var(--color-divider) by default to visually close the preceding section. Do not insert <hr> elements between sections — they duplicate the divider. The border on h2 is the only section separator needed.
The header-divider rhythm is fixed: heading, then --padding-500 (20px) of clearance, then the rule, then --gap-1000 (40px) before the section's content. The rule belongs to the heading, so the tight side is above it and the generous side is below — inverted spacing (a rule floating far under its heading, or content hugging the rule) reads as the rule labelling the content instead. SectionTitle owns the heading-to-rule half; the page's section layout owns the 40px below, usually as the section container's gap. A page whose sections run a tighter internal gap must make up the difference on the divider itself (margin-bottom on the title — /overview and /docs/get-started are the precedent) rather than letting the rule sit closer to the content than everywhere else. The one deliberate exception is markdown article bodies (the /blueprints renderers — see Applying to Markdown Pages below): their h2s head running article copy, not page sections, so they run a tighter internal rhythm (8px above the rule, 16px below) that would read as page-section spacing nowhere else.
Applying to Markdown Pages
When a page renders markdown (via react-markdown or similar), apply these heading styles using :global() selectors scoped to the markdown body container:
/* h2 — major section, light and airy */
.markdownBody :global(h2) {
font-size: var(--font-sub-display-size); /* 30px */
font-weight: var(--font-sub-display-weight); /* 300 */
padding-bottom: var(--primitive-padding-200); /* 8px above the rule */
border-bottom: var(--border-025) solid var(--color-divider);
margin-top: var(--gap-1500); /* 60px above, 40px on mobile — semantic, so the section rhythm collapses with the token */
margin-bottom: var(--primitive-gap-400); /* 16px below */
}
/* h3 — sub-section, bold contrast */
.markdownBody :global(h3) {
font-size: var(--font-heading-3-size); /* 22px */
font-weight: var(--font-heading-3-weight); /* 600 */
margin-top: var(--primitive-gap-1000); /* 40px above */
margin-bottom: var(--primitive-gap-200); /* 8px below */
}
/* h4 — minor heading, same weight as h3, smaller size */
.markdownBody :global(h4) {
font-size: var(--font-title-body-size); /* 16px */
font-weight: var(--font-title-body-weight); /* 600 */
margin-top: var(--primitive-gap-400);
margin-bottom: var(--primitive-gap-100);
}
Responsive Collapse
The display tier collapses in the token layer, once, at 768px (see the Hierarchy table): page titles (--font-display-2-*) go 64px → 40px and section heads (--font-sub-display-*) go 30px → 24px with no per-page CSS. Pages that reference the tokens are responsive by default — never re-hardcode a mobile font size in a page module. One sanctioned exception, marked with a ds-allow(typography) directive in the package: the chat widget's welcome ask line repeats the sub-display step as a container query on the panel's own width (src/chat/ChatWidget.css), because the docked panel is phone-width on desktop windows the viewport media query calls large — the panel wears the phone scale below a 768px container, and only the fullscreen takeover on a wide window keeps the 30px size. It restates the same 24px/36px pair, and the two must move together.
- Headings 1–3 and the body tiers do not step; 22–30px headings stay readable on phones.
- Weights do not change at any breakpoint.
- The one sanctioned exception: a mega-1 hero (home page, 404) may keep a hand-tuned two-stage ramp (132 → 64 → 40) in its own module, commented as a deliberate override.
Spacing & Layout
Base Units
- Gap (flex/grid spacing): 4px-based scale via
--gap-*tokens. - Padding (internal element padding): 2px-based micro scale via
--padding-*tokens. - Borders: 1px hairline (
--border-025) and 2px emphasis (--border-050).
Gap Scale
| Token | Value | Typical Use |
|---|---|---|
--gap-050 | 2px | Tightest inline spacing (icon + label micro-gap) |
--gap-100 | 4px | Compact component internals |
--gap-200 | 8px | Standard icon-to-label gap, button icon spacing |
--gap-300 | 12px | Form field stack spacing |
--gap-400 | 16px | Card internal element spacing |
--gap-500 | 20px | Section element spacing |
--gap-1000 | 40px | Card-to-card spacing, major internal gaps |
--gap-1500 | 60px (40px ≤768px) | Between component groups |
--gap-2000 | 80px (60px ≤768px) | Between major page sections |
--gap-3000 | 120px (80px ≤768px) | Page-level section breaks |
The three section-rhythm steps (xxl–xxxxl) compress one notch down the primitive scale below 768px, in a @media block at the bottom of tokens-light.css — the same single token-layer breakpoint typography uses. Steps xl and below never change.
Padding Scale
| Token | Value | Typical Use |
|---|---|---|
--padding-050 | 2px | Badge vertical padding |
--padding-100 | 4px | Micro insets |
--padding-150 | 6px | Compact button/input vertical |
--padding-200 | 8px | Standard button vertical, badge horizontal |
--padding-300 | 12px | Compact button horizontal, compact input horizontal |
--padding-400 | 16px | Standard input horizontal, card inner padding baseline |
--padding-500 | 20px | Standard button horizontal |
--padding-1000 | 40px | Large card insets |
--padding-1500 | 60px (40px ≤768px) | Section-level insets |
Layout Philosophy
Whitespace communicates hierarchy. Dense elements use micro-gaps (2–8px); comfortable reading areas use md/lg gaps (16–20px); major layout regions use xl–xxxxl (40–120px). The system relies on spacing contrast rather than dividers — overuse of --color-divider is a design smell.
App shells
An app built from the package lays out in one of three app shell modes, how the navigation rail and the content meet the page. They are defined once, in the shell recipe src/behaviors/shell.css: a host marks its root with data-ds-shell (and, in flush, data-ds-shell-backdrop), and reads the recipe's --ds-shell-* properties instead of restating a token. AppLayout, AppSidebar, ChatWidget (as a page), the site's TemplateShell and the CLI's app-shell block all draw from it, so a mode looks the same wherever it is used; scripts/validate-shell-recipe.mjs holds every host to it. A mode decides four things, the plane, the surface, the rail and the edge, and the edge belongs to the mode, never a choice of its own:
- Flush. No gap and nothing raised. The rail runs edge to edge on
--color-bg-app-shellbehind a--border-025--color-bg-container-borderdivider; the content is crisp on--color-bg-app-surfacebeside it. A chat seated as a page (ChatWidget'slayout) has one lever more, the backdrop:blur, the site chat's takeover (--color-bg-glassand a 40px blur, drawn as a fixed layer behind the host so its fixed descendants keep the viewport), with the rail and the conversation clear on it and the divider still between them, orsolid. Blur is the signifier that the chat rests above something, as the site chat rests over a page, so it is a chat's alone: an app layout, a template or the app-shell block is always the floor of its page and stays solid. - Floating. The rail is a glass card (
--color-bg-glass, a 24px blur,--radius-600, the hairline and--shadow-floating) held off the edges by--ds-sidebar-float-inset; the content sits on--color-bg-page-primary. - Inset. The rail runs flush on
--color-bg-app-shelland the content is a raised surface on--color-bg-app-surface, the floating card's edge turned around (the hairline and--shadow-floating,--radius-600), held off the other edges by--ds-shell-insetand scrolling inside itself. A docked chat beside it is the surface's twin.
Every rail in every mode shares one geometry, AppSidebar's: the logo, the row icons, the group headings, the footer controls and the avatar sit on one leading line 20px in; the collapse toggle sits on one trailing line 20px in and 20px down; a row's own trailing item (a count badge, a time) keeps a step more room inside its pill, 24px in. The rails share their furniture too: the collapse toggle washes a passive pill and carries a Tooltip of its label, group headings are --font-paragraph-sm-* in --color-text-tertiary, mixed case, and the profile block is a 40px Avatar with the name in --font-paragraph-sm-emphasis-* over a second line in --font-caption-* on --color-text-secondary, the pairing EntitySwitcher's trigger uses. Below the mobile breakpoint every mode gives way to the plain page, and the rail becomes a drawer; a chat narrower than its inline rail threshold gives way the same way, flush on the solid backdrop, wherever the viewport is. The site's templates open inset and the chat home opens flush with the blur backdrop; each keeps the visitor's own pick (website/src/lib/templateLayout.ts). Foundations, Layout, App shells is the public reference.
Composition
Tokens constrain values; this section constrains choices. Element-level rules (colors, radii, type) make components consistent — these rules make pages consistent. Every rule here is stated so that a page assembled from the components lands right on the first pass, without per-page taste decisions.
The rhythm ladder
Page-level vertical space is a statement of relatedness: the gap between two things says how related they are. The ladder is exhaustive — these five roles are the only page-level gaps. A spacing value that is not on the ladder is component-internal spacing, and belongs to a component.
| Role | Token | Value | Meaning |
|---|---|---|---|
| Section break | --gap-2000 | 80px (60px ≤768px) | Separates page regions. The only gap between sections. |
| Heading bind | --gap-1000 | 40px, flat | Ties a heading to the content it introduces. |
| Intro bind | --gap-500 | 20px | Ties a tagline or lede to its body copy. |
| Group bind | --gap-200 | 8px | Ties a label to the items it names. |
| Column gutters | --layout-* | presets | Gutters and rail widths are facts of the layout, set once at the shell — never per-page choices. |
Because the ladder's meanings differ by a full visual step (80 / 40 / 20 / 8), a reader can recover the page structure from spacing alone. Never use an off-ladder gap to "fine-tune" a page-level relationship: if 40px feels wrong between a heading and its content, the content is wrong, not the gap.
The dashboard rung set
Rule 7 below says tiling is a template's decision; a dashboard is that template. When a page is an app shell laying Panels in a grid, the ladder gains one inner rung set, worked out on the labs marketing rebuild and stated here so every dashboard lands on the same density:
| Role | Token | Value | Meaning |
|---|---|---|---|
| Card gutter | --gap-500 | 20px | Between Panels, in every direction. |
| Card inset | --padding-500 | 20px | A Panel's own padding — the gutter and the inset match, so the grid reads as one fabric. |
| Region gap | --gap-400 | 16px | Between a Panel's regions: header to chart, chart to legend row. |
| Cluster gap | --gap-200 / --gap-300 | 8 / 12px | Within a cluster: icon to text, tiles in a legend row, buttons in a toolbar. |
The recurring mistake this prevents: reaching for the 2px and 4px tokens (--gap-050, --gap-100) for cluster spacing — both spacing audits on the labs rebuild found the same error, and at cluster scale those values read as touching, not spaced.
Composition rules
- Parent owns spacing. Siblings are spaced by their container's
gap, never by margins on the children. A child that carries its own outer margin breaks in every context except the one it was tuned for. - One level of chrome. Components that carry their own container (charts, tables, calendars) sit bare under a heading — never wrapped in a card. A card wrapper is only for content with no chrome of its own. Two levels of container on one element is always a bug — and so is the over-correction: bare means without a wrapper, never with the component's own borders and background stripped to fake flatness. The component keeps exactly the chrome it ships with.
- Dividers are the last resort. Separation comes from whitespace (the ladder) and containers first; a divider is only for where spacing contrast cannot do the job — long uniform lists, table rows. A divider under a heading that whitespace already separates is decoration, and decoration is not a divider's job. Next to content that draws its own lines (bordered tables, calendars), a section heading always separates by whitespace alone — on a line-heavy page, every added line is noise.
- Constrain the column, not the content. A page has one content max-width; anything wider than its column (tables, diagrams, code) scrolls inside its own container. Never cap the width of a paragraph — cap the column it sits in.
- A header is one region. A page title and its tagline bind at the heading tier (40px), never separated by a section break. The first section break on a page sits below the complete header group.
- Page rhythm stops at a component's edge. The ladder governs the space between components and headings; a component's internal spacing is its own spec's business. Composing a page never involves reaching into a component to adjust its insides.
- Sections stack. A page is one column of full-width sections; side-by-side placement is for the items inside a band (a metrics row of Stats), not for sections themselves. Tiling sections into a grid is a template's decision to make, never a page-by-page one — tested both ways on a dense dashboard, the stack reads calmer and the grid reads cramped.
Template screens
A template rebuilds a whole product screen from the system alone, and the family has conventions beyond the page rules above — worked out across the marketing dashboard, merchant sales, roadmap planner, and relay console rounds of review, and stated here so the next screen lands right without re-learning them:
The product mark is the generic disc. Every template's fictional product wears the same mark beside its name: ProductMark (website/src/components/templates/ProductMark/), a --radius-pill disc at --icon-size-600 in --color-product-mark (which aliases --color-core-ui-primary), so it follows the theme's accent through every preset without borrowing the action colour, which stays reserved for controls. No template draws a bespoke logo for its own product, and the site's own brand mark never stands in for a product's (the mobile dashboard's is a link back to the templates index, not the product's logo). Marks a screen shows as content are the exception, in three kinds. A real publisher or identity provider a screen names as data gets its brand-colour mark through the shared PublisherMarks table (the sign-in template's provider buttons, the marketing lab's channel column). Fictional companies a marketing screen names as customers get small invented glyphs drawn in currentColor (the landing page's customer strip), so the row reads as several brands in one quiet tone and never as a real company's mark. Language flags keep their own fixed colours, the file-format colours' reasoning: recognition is their whole job (the landing page's language menu).
- A reference product is direction, never a blueprint. A template may start from another product's screen, but it is composed from this system's idioms, and the standard for every choice is the rendered siblings, not the reference. Furniture the reference has and the family lacks — a status badge beside the title, an avatar cluster in the header, drawn imitation browser chrome, a bespoke rollup strip — does not come along: a piece with no sibling precedent is left out by default, not invented.
- One job per control species, one size per row. A data view's control bar is the library's
TableToolbarin the DataTable'stoolbarslot (merchant sales' orders), so its search, filter chips, sort, columns and bulk bar are the table's own, and a filter the screen opens on (merchant sales' current month of orders) is a default filter the reader can change, never a hand-built select beside the table. Never a mix of segmented controls, filter chips, and inputs doing the same job in one row. A stage toolbar — the strip floating over an immersive stage, the relay console's precedent — may pair one view-flipSegmentedControlwith the filter cluster, at the default size with wider selects, because it is read at arm's length over a map rather than at table density; the filters themselves stay one species. What survives every toolbar: controls sharing a row share one size, and no job is done by two species in the same row. - The stage earns the viewport. The screen's point — the table, the board, the chart — takes the room. A KPI band is a choice, not template furniture: the roadmap planner shipped without one, because the stage deserved the screen. When metric tiles do appear, they follow the dashboard rung set above.
- People are initials. Demo humans render through the Avatar's initials fallback — never generated portrait imagery or photographs. The fiction stays legible as fiction, and the rows stay quiet.
- In the floating layout the content column caps at 1680px — unless the stage is the content, or the screen is a phone (rule 6). The family's shell,
TemplateShell(rule 9), centres the column inside the viewport-tall shell in the floating layout; in the inset layout, the default, the column runs full width in the surface whatever cap the template sets. A screen whose stage is the content — a map, a projection instrument — may run the full viewport width instead (the relay console's call); it is a stage decision, made once per screen, never a per-section one. - A phone-only screen is the phone and nothing else. At a phone's width the app is the page, edge to edge; from 600px it is drawn as a framed device on the dotted stage, with the status bar and home indicator as
aria-hiddenset dressing overlaying the app, and the platform chrome (TopAppBar, BottomNav) riding the app area rather than the viewport so the frame carries it. The way back to /templates and the theme switchers sit on the stage, moving into the menu drawer at phone width. Its filter is one full-widthSegmentedControlper view, the phone form of rule 2, and its sidebar entry setsmobileOnlyso the templates carousel previews it in the phone frame at every device size. The mobile dashboard is the precedent (its entry also setshideFromShowcase, which keeps a template out of the carousel while its page, sidebar entry and sitemap row stay). - In a Mobile preview, an app shell previews as an app. The templates index draws every preview in a phone bezel at Mobile, and a template with a sidebar shell swaps its web chrome for the mobile dashboard's there: an iOS large-title
TopAppBarin place of the top bar and page title, and a glassBottomNavbuilt from the sidebar's first destinations, the assistant on its search circle, and the top bar's menu button opening a leftDrawerwith the rest of the rail (the entity switcher, every destination as aNavList, the profile at the foot), so nothing the rail carries is out of reach. A Mobile preview's page draws no scrollbar gutter, as a phone does not (html[data-app-frame]in the site's globals), so the app sits centred in the bezel. The templates carousel's Mobile mode and the playground's Mobile stage both ask for it with adata-app-frameattribute on the preview frame's root (a Desktop stage dragged narrow never sets it: a narrow window is not a phone), and the sharedAppFramepieces inwebsite/src/components/templates/AppFrame/do the dressing, so the template's own page is untouched at every width. A screen with no tab-bar shape on a real phone (a chat session, a sign-in) keeps its own narrow layout inside the bezel. A screen with no sidebar may read the same flag for phone chrome of its own: the landing page shows its bottom tab bar for a Mobile preview and on a real touch phone, never for a narrow desktop window. - The assistant follows the site chat's responsive modes. A template's chat is the shared
TemplateAssistant(or, on a screen that is itself a chat surface, a restatement of the launcher's contract over the package'sChatWidget, as the chat home does), and it behaves as the site panel does: docked atDOCK_QUERYwith the shell's main column reserving the width, a modal overlay over a scrim below that, a full-viewport takeover at phone widths. The component owns the modes, andTemplateShellreserves the width only at the dock threshold, because a reservation below it squeezes the stage into a sliver (a screen outside the shell gates its own.mainChatOpenpadding on@media (min-width: 1440px)). The assistant reads the shell's layout: in the inset layout it docks at--layout-shell-insetas a plane matching the inset surface beside it, its hairline and--shadow-floatingincluded (ChatLauncher'ssurface="inset"restated); in the flush layout it docks edge to edge behind a hairline, nothing raised, and as the overlay it takes the same gap but keeps its shadow. Docked, it carries the site chat's widen grip, which rewrites--ds-chat-widthframe by frame so the reserved room follows the edge. A phone-framed screen passesplacement="contained"and places the panel inside its frame. The ChatWidget spec owns the rule for every chat surface, not only templates. A screen where the chat is the page (the chat home) is the one shape outside this: it hosts the realChatWidgetas the whole viewport, with no close and no full-screen toggle, in the takeover form at every width; the ChatWidget spec records the exception. A screen demonstrating a live assistant passes a realChatWidgetasTemplateAssistant'schat: the panel keeps its modes and grip, adds the launcher's motion and full-screen view, and the top bar'sAiButtontoggles it and carriesuseChatAgentState. Merchant sales is the one that does; the family's assistant is otherwise the mock. - One shell, three layouts. Every template with a sidebar lays out through
TemplateShell(website/src/components/templates/TemplateShell/), which owns the rail and its footer switchers, the main column, the docked assistant's room and the Mobile-preview swap (rule 8); a template styles only its content column. The shell draws the three app shell modes:flush, the rail on the shell colour behind its hairline with the content centred on the crisp surface,floating, the glass rail with the content centred (rule 5's cap), andinset, the flush rail on--color-bg-app-shellwith the content full width in a surface on--color-bg-app-surfaceheld off the edges by--layout-shell-inset, scrolling inside itself. The surface wears the floating rail's edge turned around (the--color-bg-container-borderhairline and--shadow-floating), its thin scrollbar always shows, and its content fades out over the last--gap-1000at the floor through the chat thread's mask, never a blur; all three give way below the mobile breakpoint, where the page scrolls. The visitor picks one with the icon switch at the end of the sidebar footer's theme row (collapsed, the rail keeps the same three in itscollapsedFooterSlot, stacked, the header's condensed form: the compact theme switcher, then the colour mode toggle and the layout switch as SegmentedControlcyclecircles,CollapsedSidebarSwitchers), and the choice is stored (website/src/lib/templateLayout.ts, inset by default) so it follows them between templates. The content column is a size container (template-content), so a screen restacks by the room it has, which an open chat or an expanded rail changes without the viewport moving. The rail always runs AppSidebar's panel mode with the side panel closed on arrival, to demonstrate the secondary nav: a row with demo children opens them beside the rail. A screen that passesentitySwitchergets anEntitySwitcherunder the brand row in the rail'spersistentTopSlot, its mark holding still as the rail collapses; merchant sales wears one, Larkspur's stores, its sample tree picked with?entities=(deep, the default,flat,singleoroff). The sidebar footer carries the way back to /templates (a compact tertiaryButton) above the theme row, and every template screen with that footer row carries it the same way, the sign-in pane, the landing page's footer and menu drawer and the chat home's history rail included (SidebarSwitchers'backLink, orTemplatesBackLinkwhere the controls sit in a row of their own; hidden when the playground stages the screen). The phone-only screen keeps rule 6's link on its stage. The template's top bar (search, then Ask AI and notifications at the default size) wraps inStickyTopBarfrom the shell's folder: it holds at the top of whatever scrolls, with the content running under it, bare at rest, then a fill in the scroller's colour above it and the site header'sProgressiveBlurbehind it once the content leaves the top; no rule is drawn under it.
Blocks
A block is a whole starting page a consumer copies into their app with npx rift-ds init block <name> and owns from then on, so it never updates under them: the template family's idea cut to what a new app needs on day one. It is source, never a component: no prop is published, so nothing joins the API compatibility gate, and a piece that proves itself across blocks is promoted into the library rather than grown in place. The files live at their target paths under src/cli/templates/blocks/, listed in that folder's registry.json, and the site renders each one at /docs/blocks/<name> from the same file, so the preview and the shipped page cannot differ. scripts/validate-blocks.mjs holds the registry, the file tree, the imports between the files and the site's previews to one another.
- Composed from the library alone, styled with tokens alone. A block's CSS modules name semantic tokens for every colour, gap, padding, radius and type style; the only literals are a component's own geometry the block must clear (AppSidebar's rail widths, its mobile menu button) and a content width, each said in a comment.
- Responsive at every width. Every block is laid out for a desktop, a laptop, a tablet and a phone, on the library's breakpoints: the app shell's sidebar floats beside the page above the mobile step and becomes AppSidebar's drawer below it, the top bar's search folds to its icon, the gutter tightens, and grids step down to one column. A block that only holds at one width is not finished.
- One shell, shared. The app shell (
components/blocks/AppShell.tsx) is the sidebar in one of the three app shell modes, picked bySHELL_LAYOUTat the top of the file (floating as written), the top bar (CommandPaletteTrigger, AiButton, ThemeToggle, every control at the default size so the row holds one height; it sticks one inset off the top and the page scrolls under it, bare at rest, then a page-colour fill above it and aProgressiveBlurbehind it once the page scrolls) and the page header (Breadcrumb, an h1 at Heading 1, compact actions), around a content column capped at 1440px. Blocks that need it list it, andinit blocknever overwrites, so the first writes it and the rest reuse it. - Placeholder content reads as finished. The product is "Acme", the people are plain names, the numbers are plausible, and every comment tells the consumer what to replace, so a block looks right the moment it lands and is obvious to make their own.
Adding a block touches five places, and the build fails on any one missed: the files at their target paths under src/cli/templates/blocks/ with an entry in its registry.json (validate-blocks.mjs), the block's page in BLOCK_PREVIEWS (website/src/data/block-previews.tsx), a preview route folder under website/src/app/docs/blocks/<name>/ copied from a sibling, that route in CHROMELESS_ROUTES, and a reason in the corpus generator's EXCLUDED_ROUTES (validate-chat-coverage.mjs). The index page, the bin's list and the consumer smoke read the registry and need nothing.
Shapes
Border Radius Scale
Concentric containers. A container wrapping a radiused element derives its own radius as the inner radius plus the inset between them, never an independent value — parallel curves are what make the nesting read deliberate, and a theme that squares the pill token squares both at once. The Composer's shell is the founding case (its spec section owns the derivation); SegmentedControl's track (calc(var(--radius-pill) + var(--padding-100) + var(--border-025))) follows it. At the base theme's 999px pill the sum saturates and nothing changes.
A row of controls shares one curve, and the deepest nesting sets it. Concentricity answers what a container's corner should be when something sits inside it; it says nothing about the control next to it, and the two can disagree. The site header's right slot is where that showed: the search pill, the theme switcher's trigger, the background switcher's trigger and the light/dark toggle are all 40px tall and wear the same fill and hairline border, but each derived its corner from its own contents, so a theme that squared the pill token gave the row three curves — 14px, 10px and 6px in Mono. The toggle's track is the constrained one, because its segments genuinely nest inside it, so the row matches the track: every control in it takes calc(var(--radius-pill) + var(--padding-100)) (CommandPaletteTrigger and ThemePicker's trigger both wear it), and the theme picker's trigger does not derive from the swatch it carries. A chip inset 11px inside a 40px button is a mark, not a nested track, and reading a curve off it bowed the trigger rounder than its neighbour in every squared theme. The row shares a height for the same reason: --control-height-default, the height every row control takes (see the Control Height Scale), which SegmentedControl's track reaches by shrinking its segments inside it rather than padding them.
| Token | Value | Use |
|---|---|---|
--radius-050 | 2px | Reserved — rare micro elements |
--radius-100 | 4px | Badges (ds-badge) |
--radius-200 | 8px | Small sub-elements, inner nested surfaces |
--radius-300 | 12px | Inputs, cards (standard), modals |
--radius-400 | 16px | Large feature cards, hero containers |
--radius-600 | 24px | Card/EntityCard navigation tiles, Panel, chat bubbles and chat card furniture (MessageCard, ToolCall, AgentPlan, InterruptCard), AppSidebar's floating variant, AppLayout's inset surface, CommandPalette's panel, oversized hero containers, page-level sections |
--radius-1200 | 48px | Pill containers, oversized decorative elements |
--radius-pill | 999px | All buttons, every variant, toggle thumbs |
The Value column is the base theme's scale; the radius lever rescales every step per theme, and its pill switch can square --radius-pill down to the input's corner (the scaled --radius-300), so in a squared theme a button and the field beside it share one corner.
Key rule: Buttons are always --radius-pill. Inputs are always --radius-300, with one sanctioned departure: the Composer shell holds its own component-local --ds-composer-radius for concentric geometry with its send button (see the Composer spec). Card and EntityCard — the navigational tiles — use the larger --radius-600 to read as destinations rather than form surfaces, and the chat's card furniture (MessageCard, ToolCall, AgentPlan, InterruptCard) shares that shell with the bubbles around it, so a conversation's surfaces read as one family; a choice offered as a row (SelectionCard, QuestionCard) is a form control and keeps the input radius, and a row inside an overlay takes --radius-200, the inner step of its panel. The assignment is what is fixed, not the curve: in the base theme it reads as pill CTAs vs softer-cornered inputs vs generously rounded tiles, and a squared theme keeps the same ordering at tighter values.
Icon Size Scale
| Token | Value | Use |
|---|---|---|
--icon-size-500 | 20px | Compact controls, inline affordances, chips, list chevrons |
--icon-size-600 | 24px | Default — the size an icon is unless told otherwise |
--icon-size-800 | 32px | Feature icons — EntityCard, section headers |
--icon-size-1200 | 48px | Marketing and empty-state illustration icons |
Control Height Scale
Every control that can share a row takes one height at each size, border included, so a search field, a picker, a button, a circular button and a segmented control set side by side line up exactly, with no per-seat patch.
| Token | Value | Use |
|---|---|---|
--control-height-default | 40px | Default controls: fields, pickers, Button, CircularButton, AiButton, SegmentedControl's track, ToggleGroup, FilterBar's chips |
--control-height-compact | 32px | The same controls at size="compact" |
A control sets the token as its height with box-sizing: border-box and keeps only its horizontal padding, so a bordered control (a field, the secondary Button) and a borderless one (the primary Button) measure the same. SegmentedControl keeps its 4px track padding and sizes its segments to fit inside the height. Combobox takes the token as a minimum, with its border taken out of the vertical padding, because selected chips may wrap it onto a second line, and so does RichDropdown, keeping its vertical padding, because its trigger previews the chosen option's two-line cell. A tag (Chip) and a keyboard hint (Kbd) are content, not row controls, and keep their own heights. A control of your own that sits in a row takes the same token.
The icon tile pattern — an icon on a filled circle, for KPI cards and table cells: a --radius-pill disc in --color-icon-bg holding a --icon-size-500 glyph in --color-text-primary, at 40px (stat and KPI headers) or 32px (table cells and list rows). It is a page pattern, not a component: four declarations in module CSS, worked out on the labs marketing rebuild.
Components set --icon-size, never font-size:
.ds-thing__icon { --icon-size: var(--icon-size-500); }
Node-accepting icon props — every icon-shaped prop in the library (item-level entries like a tab's or a menu row's included) is typed string | React.ReactNode: a string renders as a Material Symbol glyph, and any other node renders as-is inside the same wrapper span, minus the material-symbols-rounded class. That contract is the bring-your-own-icons paved path — the repo imports no icon set, and a consumer passes Lucide (or any other) elements directly; a custom SVG should draw with currentColor so it inherits text colour the way the bundled glyphs do. Props with a string default (TreeView's folder/draft, Toast's variant glyphs, ThreadPanel's edit_square) keep resolving those defaults when the prop is omitted. New components must ship icon props on this contract, never string-only.
Icon style hooks — the bundled woff2 is the full Material Symbols variable font, and src/fonts/material-symbols.css exposes every Google axis as a consumed custom property, settable at any scope: --material-symbols-fill (0 line / 1 filled), --material-symbols-weight (100–700 stroke), --material-symbols-grade (−50–200 contrast). opsz is deliberately absent from font-variation-settings so font-optical-sizing: auto feeds the rendered size into the axis (an explicit value would pin it). Three shipped applications of the hooks: [data-theme="dark"] sets grade −25 so light-on-dark glyphs do not bloom, .icon-filled is the one-icon shorthand for the fill axis, and a theme preset may set the weight and fill for the whole look through its iconWeight and iconFill levers. These are consumer-override hooks with fallbacks, not registry tokens — the fallback values in that file are their defaults.
.material-symbols-rounded reads that one property for font-size, width, and height together, so the glyph and its layout box can never disagree. It consumes the variable and never declares it — which is what keeps a component rule from colliding with the icon font's own styles. Setting font-size directly on an icon is a bug: it changes the glyph without changing the box.
The scale starts at 20px, and that floor is not arbitrary. Material Symbols is a variable font whose opsz (optical size) axis ranges 20–48. Optical sizing thickens strokes as an icon gets smaller; below 20 the axis clamps, so a 14px icon would be drawn with 20px-tuned strokes and scaled down, reading thin and fragile. Every step therefore sits inside the axis range, and font-optical-sizing: auto lets the browser track the step automatically — opsz is deliberately omitted from font-variation-settings, because an explicit axis value there would override the automatic behaviour.
Documented exceptions. Glyphs that live inside a control's geometry rather than acting as icons stay off the scale: the check inside SwitchIndicator's 20×20 thumb (14px, 12px compact), which ToggleSwitch, InspectorToggleSwitch and SelectionCard's toggle mode all draw, and the miniature component mock-ups in the website's component index cards. Each is commented in place.
Dark mode applies GRAD: -25 to counter the optical bloom of light glyphs on dark surfaces — it thins strokes without changing glyph width, which is what wght would do.
Elevation & Depth
The system uses color-block first, shadow rare philosophy. Depth is communicated through contrast between the floor and the container steps, not box shadows. Each step only has to separate from the one it sits on; whether it reads lighter or deeper is the theme's call.
| Level | Treatment | Use |
|---|---|---|
| Page floor | --color-bg-page-primary — no border, no shadow | Body background, app root |
| Container primary | --color-bg-container-primary — no border | Sidebars, card fills, section bands |
| Container secondary | --color-bg-container-secondary — optional --color-bg-container-border border | Nested cards, inner panels |
| Container tertiary | --color-bg-container-tertiary — no shadow | Pressed/active states, deepest UI surfaces |
| Input | --color-input-bg-primary with --color-input-border-primary hairline | Text inputs, textareas, dropdowns |
| Status containers | Colored bg + colored border per variant | Alerts, toasts, badges |
Standard containers never carry shadows. The only shadows in the system are three semantic elevation tokens, defined per mode (stronger opacity in dark mode so they read against the near-black floor) and per theme: the elevation lever can deepen them, soften them, or take them to none, in which case the hairline borders carry the depth alone. The values below are the base theme's:
--shadow-floating(0 4px 16px rgba(0,0,0,0.12)light /0.55dark) — anchored floating surfaces: menus, popovers, pickers, floating panels, chart tooltips, toasts. Any component that floats over the page without a scrim takes this token; grep--shadow-floatingfor the current set rather than trusting a list here.--shadow-modal(0 8px 32px rgba(0,0,0,0.2)light /0.6dark) — modal surfaces: Dialog, AlertDialog, and Drawer panels and Lightbox's media, paired with the--color-scrimbackdrop.--shadow-table-pinned(0 0 12px rgba(0,0,0,0.16)light /0.6dark) — the edge of a pinned DataTable column while content scrolls under it, cast by the whole column and clipped to the strip past its inner edge, so it falls on the cells passing beneath and never on the pinned column. The pinned edge always carries a hairline; the shadow only says something is under it.
Never write a literal elevation shadow in component CSS — use one of these tokens or no shadow at all. Hairline inset rings and focus halos built with box-shadow are not elevation and are permitted: Swatch and ColorPicker use inset rings to keep pale swatches legible, and Slider's thumb carries a box-shadow focus halo. (Two documented elevation exceptions: the interactive Card hover lift — see Do's and Don'ts — and the iOS glass of BottomNav and TopAppBar, which restates the platform's own shadow under the platform directive category; the BottomNav spec owns it.)
Stacking
Depth on the page has a second axis: which surface sits over which. Every surface that rises out of its component into the page's layers takes a --layer-* token, declared lowest to highest in tokens-light.css with what sits on each layer beside it, and rendered on /foundations/layout. From the bottom: --layer-dropdown (menus and panels anchored to a control) and --layer-sticky (bars pinned while the page scrolls) share a step, since the two never compete for the same space; --layer-floating (hover cards and context menus, and tooltips only where the top layer is missing, below); the mobile navigation's --layer-nav, --layer-nav-scrim and --layer-nav-rail; --layer-modal (dialogs, drawers and viewers), level with the app rail it covers by DOM order; --layer-command for the command palette, which can open over a modal; --layer-toast, above every modal, because feedback has to be seen; then the chat launcher's --layer-chat-launcher, --layer-chat-scrim and --layer-chat-panel. A surface that must sit exactly one step off a layer (AppSidebar's panel under its rail, the dock grip over the chat panel) writes calc() off the token rather than a new number.
Tooltips always sit on top. Every tooltip (Tooltip, SourceChip's preview, SegmentedControl's icon-strip labels) paints in the browser's top layer while it shows, through src/behaviors/useTopLayer.ts: a manual popover, above every z-index and every stacking context on the page, the chat panel and an open modal included, so no surface can cover one. The panel keeps its place in the DOM, so a scoped theme still reaches it; --layer-floating only ranks it in a browser without the Popover API. scripts/validate-stacking.mjs fails the build on an anchored role="tooltip" panel that does not use the hook.
Order inside a single component (a label over a sliding pill, a handle over its track) stays a small local integer under 10. A larger local number takes a ds-allow(stacking) directive saying why; scripts/validate-stacking.mjs fails the build on anything else. The site adds --layer-site-* layers above the library's in its own stylesheet, for its header, inspect mode, and the overlays that must cover the chat panel.
Motion
Motion is quiet and functional — it confirms an interaction, reveals structure, or signals loading. Interface motion never decorates. The one sanctioned exception is the ambient background, which is scenery rather than interface: it belongs to no control, communicates no state, and sits behind everything at z-index: -1 (see the Ambient background section below). The whole vocabulary is defined in tokens-motion.css (theme-agnostic, single :root like typography): ten --motion-duration-* tokens and five --motion-ease-* curves. Compose a duration with an easing instead of writing literal values — never hardcode 0.2s ease in component CSS.
Durations — core scale (day-to-day UI):
| Token | Value | Use |
|---|---|---|
--motion-duration-fast | 150ms | Quick feedback: hovers, icons, tooltips, nav links |
--motion-duration-base | 200ms | The default — color/opacity/border transitions |
--motion-duration-slow | 300ms | Structural change: accordion, sidebar width, toast enter |
--motion-duration-slower | 600ms | Deliberate page-entrance reveals |
Durations — extended (special-purpose; named so they stop being magic numbers, but reach for the core four first): --motion-duration-instant (75ms, below the threshold where a change reads as motion — it takes the edge off the Composer growing a line, nothing more), --motion-duration-deliberate (400ms, carousel slide), --motion-duration-orbit (120000ms, one full turn of an auto-rotating surface — Globe reads it from its computed style and divides the shipped value, mirrored as MOTION_ORBIT_REFERENCE_MS in src/tokens/motion.ts and build-held to the token, by it to pace autoRotate, so unlike the loop periods it is a tempo, and the playground's Motion lever scales it with the schedule durations), --motion-duration-loop-spin (1000ms, spinner rotation), --motion-duration-loop-shimmer (1800ms, skeleton shimmer and the agent label sweep), --motion-duration-loop-matrix (1400ms, the twelve-slot cycle every AgentStatus dot pattern shares).
Easings:
| Token | Value | Use |
|---|---|---|
--motion-ease-standard | ease | General default for color/opacity transitions |
--motion-ease-emphasized | cubic-bezier(0.4, 0, 0.2, 1) | Size/layout changes (sidebar width, accordion height) |
--motion-ease-entrance | cubic-bezier(0.16, 1, 0.3, 1) | Expressive decelerate for enter animations (modals, dropdowns, toasts) |
--motion-ease-linear | linear | Continuous motion (spinner, progress bars) |
--motion-ease-spring | cubic-bezier(0.34, 1.56, 0.64, 1) | Playful overshoot (toggle switch thumb) |
Reduced motion contract: tokens-motion.css collapses every duration token to 0.01ms under prefers-reduced-motion: reduce, and a universal guard flattens remaining hardcoded transitions/animations. Components that consume the tokens respect the preference automatically, so a component writes its own prefers-reduced-motion query for exactly two jobs: parking a looping animation, which the collapsed duration would strobe rather than stop (an infinite keyframe loop at 0.01ms still runs), and dropping an entrance outright. Each such query carries a ds-allow(motion) comment directly above it naming which job it does, and scripts/validate-raw-values.mjs fails the build on one without.
Migration status: all component and website CSS composes the tokens — a literal duration or easing survives only where a ds-allow(motion) directive sanctions it in place (Skeleton's shimmer curve, the ambient AI glows, the decorative background floats; grep ds-allow(motion) for the current set — a count here would only rot). JS-driven timings (hover show/hide delays and the hover exit grace, toast auto-dismiss and the exit-animation unmount sync, carousel autoplay, transient feedback resets, scroll settle, the streaming reveal's rate floor and drain window, the summary panel's think beat and reveal budget…) have their own single home: the shared constants in src/tokens/motion.ts, published as rift-ds/tokens/motion — the module is the authoritative list. Most are schedule timings rather than animation durations, which the reduced-motion guard deliberately does not touch (the summary panel's think beat is one: it still stages under the preference, because it schedules a swap rather than pacing motion); components use them as defaults, overridable per instance through props. A component that animates from a JavaScript timer or frame loop checks the preference itself, since the CSS guard cannot see JS — or rides a library default that does: the recharts-backed charts animate their marks in under recharts' own isAnimationActive: 'auto' guard, which the chart-set spec owns (a literal true would override it). A grep for prefers-reduced-motion in src/components is therefore the inventory of self-checking components only (Carousel never starts autoplay under it, and StreamingText skips its reveal so each chunk appears whole; they are examples, not the set) — the delegated recharts set never contains the string.
Ambient background
The website's default background is ShaderField — the published component, not a bespoke site surface. Its rendering contract is specified in the Components chapter; what follows is only why an animated ambient surface is allowed at all, and how the site composes it: a document-top absolute band (540px standard, 900px under the home page's extended marker, the full viewport only under the full-bleed one) that scrolls away with the page opening rather than following the viewport, sinking at 30% of the scroll as it goes (a scroll-driven animation in globals.css, sized per band; the full-bleed band stays anchored). A visitor can put one of the library's ShaderScene scenes in the field's place from the header's background switcher: the pick is data-background on <html>, kept in localStorage like the theme pick and put back by the layout's pre-paint script, and it paints on every page. SITE_BACKGROUNDS in website/src/lib/shaders/background-storage.ts is the registry, one row per background with the scene and its tuning for each band: Horizon sits low in the tall bands, under a hero, and shows the top half of the scene in the standard band, under a title.
Three properties make this an exception that does not erode the rule:
- It is scenery, not interface. Nothing about it is a signal, so its motion cannot be mistaken for feedback.
- It degrades to the CSS blobs. The site keeps eight blurred CSS discs painted underneath on every page — ported 1:1 to the shader's coordinate space, so the field and its fallback are the same picture, give or take the framing
cropgives the field on a narrow viewport — and drives their visibility from the status ShaderField reports. No WebGL2, a lost GPU context, a renderer that stalls before its first frame, or the config's kill switch all land on the same quiet fallback. The blobs are hidden while the renderer is still resolving, rather than painted and then swapped. The deliberate cost is that a machine which cannot run the shader shows bare page floor for that first moment before the blobs fade in. A picked scene has a narrower contract: its chunk loads only when asked for, the band stays bare while it is on its way, and a refused load falls back to the field; once mounted it reports the same three states the field does, so a scene that cannot run brings the blobs back exactly as a field that cannot run does. - It honours reduced motion in JavaScript. This is the part worth knowing, and it is the component's job rather than the site's: the guard in
tokens-motion.cssis CSS-only and cannot see arequestAnimationFrameloop. The limits on component-levelprefers-reduced-motionqueries still hold for everything that consumes the duration tokens; a JS animation loop is outside that contract by construction.
Each blob carries a 14–22s drift period inherited from the CSS blobs it replaced, scaled by the config's speed parameter — so the cycles you actually see are those periods divided by speed, not the raw numbers. Every tuneable value of the field itself — the eight field parameters and the eight blob definitions — lives in website/src/data/shader-background.json, validated against the token registry so the field can never sample a token that does not exist. One control sits outside it: in light mode the site runs the whole layer, the field and its CSS fallback together, at reduced opacity, because the field is composed against the dark floor and the same alpha over a light floor reads as a grey cast behind the page opening. That value lives on the .blur-container rule in website/src/app/globals.css.
Components
Emphasis and colour are two props. variant carries emphasis — primary (filled), secondary (outlined), tertiary (ghost) — and tone carries colour: default (the action colour), neutral (the greys), destructive (the danger colour, the --color-action-destructive-* family). Every action control that draws in more than one colour takes both: Button and CircularButton across all three emphases, SplitButton across primary and secondary, and the controls with only one shape take tone alone (SegmentedControl, ToggleGroup and Chip for their selection, AlertDialog for its whole confirmation, Spinner for its arc). Some cases sit outside the pair on purpose. Status-bearing components (Alert, Badge, Banner, Toast, StatusDot, Meter, UsageCard) keep the five status roles on variant, because their colour is their meaning. A data entry a component renders (a DropdownMenu or ContextMenu item, a CommandPalette command, a MessageActions item) marks danger with destructive: true, because an entry has no emphasis to pair a tone with; InterruptCard's options are the exception, because each is drawn as a Button with an emphasis of its own, so they take variant and tone like one. Gauge and Sparkline colour a data reading by status role, so their tone speaks that vocabulary (with the chart accent as its default), Spinner's tone adds inherit to follow the control it sits in, and Text's speaks the status roles for a message of that kind, with inherit too; the validator's SANCTIONED_TONES table records those four. Where a single-axis value predates the split, it stays as a deprecated alias that renders unchanged (variant="neutral" is primary × neutral), and scripts/validate-prop-vocabulary.mjs keeps the repo's own code on the current spelling. A component with no emphasis to express may use variant for its form instead (EmptyState's plain or bordered, AnchorNav's list or floating), and the validator's SANCTIONED_FORM_VARIANTS table records each, so a new one is a decision. size is the density axis, default and compact, the pair the theme's density lever moves; a component with a real scale of its own (Avatar's ladder, Dialog's panel widths, TopAppBar's platform sizes) keeps it, recorded in SANCTIONED_SIZES.
Overlay behaviors are shared, not per-component. The modal overlays (Dialog, AlertDialog, Drawer, CommandPalette, Lightbox, AttachmentViewer, BentoTile's overlay, and AppSidebar's mobile drawer) delegate their invisible machinery to the internal hooks in src/behaviors/ — one implementation of each behavior, so an edge-case fix lands once. The six standalone modal components (Dialog, AlertDialog, Drawer, CommandPalette, Lightbox, AttachmentViewer) are controlled by design: open is required and owned by the host, and the overlay never changes it. It reports every dismissal (Escape, a backdrop press, the close control) by calling onOpenChange(false), CommandPalette's hotkey asks to open the same way, and the host decides whether to pass the new open back. The contract: Escape routes through a layer stack and dismisses only the topmost open overlay (a non-dismissible top layer swallows it — nothing beneath can close through a blocking modal), and an Escape a component has already handled (event.preventDefault()) is left alone, which is how a listbox closing its own list keeps the Dialog around it open; focus is trapped over the visible focusable elements, and restored on close or unmount to the previously focused element when it is still in the document and visible, or else to the first visible control of its nearest visible ancestor (a button in a popover that closed underneath hands focus to the popover's trigger); everything outside the open overlay is made inert, so a screen reader's virtual cursor cannot browse the page behind a modal, except a live region such as the toast region, which keeps announcing and stays pressable; and the page scroll lock has two strategies — pin (default: the body is fixed in place at its scroll offset, because iOS Safari ignores overflow: hidden on the body — a touch, or a field focusing under the soft keyboard, still scrolls a hidden-overflow page out from under an overlay; counted, so stacked modals release it only when the last one closes) and cancel-events (CommandPalette's and BentoTile's overlay's: the scrollbar stays rendered and scroll input is cancelled outside the panel's own content, so the page cannot shift sideways under the overlay). The pin's counter is one of the layer's published subpaths (rift-ds/behaviors/useScrollLock; scripts/package-manifest.mjs lists the others, and everything else in src/behaviors/ stays internal): host chrome with overlays of its own joins the same counter through acquireScrollLock/releaseScrollLock, because two body locks that cannot see each other unlock the page under whichever overlay closes last — a site nav drawer closing beneath an open Dialog is the canonical case, and the website's lib/scroll-lock.ts is that consumer. The stack owns the document-level dismissal listeners for every overlay, modal and non-modal: every component that imports useLayer registers on it, and /foundations/accessibility prints that roster from the imports, so no list of them is kept here. The non-modal layers use light dismiss (dismissOnOutsidePress plus the elements that count as inside). An outside press walks down from the top and closes every light-dismiss layer it lands outside of, stopping at the first layer that contains it or at a modal, so a press on a Dialog's body closes the menus above it and leaves the Dialog. A modal's backdrop click asks the stack whether the press was its own (ownsPress()), because a press that began while a menu was on top was the menu's, and closing both on one press was the bug the stack exists to prevent. A visible tooltip is a layer like any other: inside a Dialog the first Escape hides it and the second closes the Dialog (WCAG 1.4.13 asks that the tooltip be dismissible without moving focus). No component keeps a document key listener of its own: CommandPalette's global Cmd+K and Lightbox's and AttachmentViewer's arrow keys are useShortcut bindings over the one shortcut listener ("Keyboard shortcuts go through one listener", below), and scripts/validate-overlay-listeners.mjs fails the build on any other.
Popups are placed by one behaviour. Every panel anchored to a control (Dropdown's, Combobox's, RichDropdown's and TimePicker's lists, DropdownMenu's panel and its sub-menus, so SplitButton's too, Popover, HoverCard, Tooltip, ModelPicker, ColorPicker, FilterBar's popovers, SourceChip's preview, AiButton's summary and request panels, ThemeToggle's icon-strip tooltips and ThemePicker's menu) is placed by src/behaviors/useAnchoredPosition.ts, not by its own CSS. The panel keeps its place in the DOM, so it stacks inside the trigger's context (a tooltip excepted: it paints in the top layer, Elevation & Depth → Stacking) and keeps any theme scoped above it, but is position: fixed, placed from the anchor's rectangle: a fixed panel is not clipped by an ancestor's overflow, so a menu in a table's scroll wrapper, a rail or a card shows whole. It takes the component's preferred side (each component's existing position, placement or align prop), flips to the opposite side when that one has too little room and the other has more, shifts along the edge to stay --gap-200 clear of the box (measuring the panel's layout size, so an entrance that scales it in cannot push it past the edge), caps a list's height to the room on its side so it scrolls inside, and holds a listbox at least as wide as its control; the box is the viewport, or a transformed or blurred ancestor (a Dialog panel, the chat panel) that becomes the fixed panel's containing block. It follows scroll and resize once per frame by writing to the DOM, never through a React render, and hides the panel while its anchor is scrolled out of view. The side it lands on is the panel's data-side, which every entrance and slide keys on, so a flipped panel grows from its control. Portals would escape the clip too, but out of the theme scope, and a positioning library would add a dependency for one behaviour; this was Tooltip's approach first, now shared. ContextMenu stays its own: it is anchored to a pointer, not a control, so it clamps to the viewport and closes on scroll. So does AnchorNav's floating panel, which opens beside its own rail, a rail the host places, typically fixed to the viewport's edge, where no ancestor clips it. src/stories/AnchoredPopups.stories.tsx opens every one inside a box that clips its overflow and fails unless the panel reaches outside the box and is what is drawn there.
Links go through the app's router. Every component that takes an href renders its links through a link component rather than a fixed <a>, so in a routed app (Next.js, React Router) they navigate without reloading the page. The link component comes from the component's own linkComponent prop, else from the nearest LinkProvider (rift-ds/behaviors/LinkProvider, the layer's second published subpath), else a plain <a>, so nothing changes for a consumer who sets neither. It receives every anchor attribute, the ref, and a string href, which Next.js's Link takes as it is; a router whose link takes to needs a one-line adapter. A provider is React context, which only a client component can read, so the server-renderable link components (Breadcrumb, CircularButton, Link, LinkList, ThreadPanel and WidgetRow) take the prop alone: a Server Component that renders them passes it on the instance. The chat widget's answer links follow the same provider through its default renderLink. AnchorNav and ErrorSummary keep a plain <a>, because their links are in-page jumps they handle themselves. scripts/validate-component-contract.mjs fails the build on any other raw <a href> in a component, and on a server-renderable component reading the provider.
Money is a decimal string. An amount travels through the library as a normalised decimal string ("1234.50", "-0.07"), the shape most payment and accounting APIs already send, never as a float, so 0.1 + 0.2 is "0.3". rift-ds/format owns the rules, with no React and no runtime dependency so a server and a client share them: parseDecimal reads what a person typed or pasted in a locale's marks (and a pasted amount in another convention, the last of . and , taken as the decimal mark), formatMoney writes it through Intl as a string so every digit survives, currencyFractionDigits gives a currency's ISO digits, and addDecimal, subtractDecimal, multiplyDecimal, compareDecimal, sumDecimal, toMinorUnits and fromMinorUnits do the arithmetic on integers. Display and parsing round half away from zero, as Intl writes numbers; arithmetic rounds half to even, as a ledger does; both take a rounding option. The same module writes a date against now (formatRelative, which picks its own unit, with minUnit: 'day' for a date-only value) and a date in a named zone (formatInTimeZone). DataTable's currency columns and relative dates format through it.
Numbers and dates are written in the consumer's locale, never the runtime's. Every component that prints a number, a date or a time, or lays out a calendar, formats through Intl in one resolved locale: the component's own locale prop, else the nearest LocaleProvider (rift-ds/behaviors/LocaleProvider, the layer's third published subpath), else en-US. The runtime's default is never used, because the server and the browser can disagree on it, which prints 1,234 in the server's HTML and 1.234 in a German browser and breaks hydration. The locale sets the digits and the grouping, decimal and percent marks of every number a component prints (the charts' tooltips, summaries and value axes, AnimatedNumber, LegendTile, file sizes, the Inspector slider's reading, DataTable's typed columns and percentages, a calendar's day numbers, Pagination's pages, Stepper's and Timeline's step numbers, the counts on NotificationCenter's tabs, Widget and SourceChip, ProgressBar's and Meter's percentages, DocumentViewer's zoom, CurrencyInput's amounts, and Gauge's and Stat's readings: ٦٤٪ in Arabic), month and weekday names, spoken dates, the first day of the week (DatePicker, DateRangePicker, EventCalendar and ContributionGraph, each with a weekStartsOn override), the distance to a Deadline, the 12- or 24-hour clock TimePicker defaults to, and the order DataTable sorts text in. It formats; it never translates: every word a component writes stays a prop with its English default, and a counted word is a function of the count, which is where a consumer plugs in plural rules. The one place the defaults stay English by design is the date pickers' two-letter weekday headers in an English locale, which Intl has no width for. A provider is React context, so the server-renderable formatters (ContributionGraph, GanttChart, Gauge, LegendTile, Meter, ProgressBar, Stat, Timeline, UsageCard and Widget) take the prop alone, as the link components do. scripts/validate-locale-formatting.mjs fails the build on a format, a collator or a localeCompare with no locale, on a hand-written month or weekday name, and on a server-renderable component reading the provider.
Keyboard shortcuts go through one listener. Every app-level key binding, the library's and the consumer's, is a useShortcut call (rift-ds/behaviors/shortcuts, the layer's fourth published subpath) over the one document keydown listener in src/behaviors/shortcuts.ts. A shortcut is a string: mod+k (⌘K on Apple platforms, Ctrl+K elsewhere), shift+?, alt+arrowup, or a sequence such as g i. The listener decides who hears a key the same topmost-only way the layer stack decides Escape: nothing fires while an IME is composing or once a component has handled the key itself (preventDefault()); nothing fires in a text field unless the binding opts in (allowInInput); Escape stays the stack's while any overlay is open; a global binding fires over anything; a scoped binding fires while focus is inside its element (Lightbox's and AttachmentViewer's arrows are scoped to their panels); and every other binding belongs to the page, quiet while a modal is open or focus is in a menu or popover. The longest sequence wins, then the binding made last, and a second binding of one shortcut in one scope warns in development. useRegisteredShortcuts lists every live binding that carries a description, so a help dialog is built from what the page actually answers to. Shortcuts are drawn by the same grammar: Kbd's keys draws keycaps in the reader's platform legends (the server draws the non-Apple ones and the browser settles after hydration), menu items' keys write ⇧⌘P or Ctrl+Shift+P and set aria-keyshortcuts, and keyLabels replaces any legend for a keyboard that labels its keys in another language. A keys hint only draws a shortcut; the binding is the host's useShortcut. scripts/validate-overlay-listeners.mjs fails the build on any other document or window key listener in the library or the site.
Layout follows the reading direction. Every component lays itself out from the reading direction, so a dir="rtl" anywhere up the tree mirrors it with no code of its own; an Arabic or Hebrew product sets dir on <html> (an overlay is portaled to the body, so the document's direction is the one it reads). Placement is logical: margins, paddings, borders, corner radii and insets are -inline-start and -inline-end, and text aligns to start and end. Motion along the inline axis has no logical property, so a slide-in, a switch's thumb, a shimmer's sweep or an optical nudge multiplies its offset by --ds-inline-sign, 1 or -1 from the element's own direction (src/behaviors/direction.css). Script reads the direction from the element at the moment it acts (src/behaviors/direction.ts): arrow keys step in reading order (ArrowLeft is the next tab, the next day, the next cell in Arabic, and expands a tree branch), and drags measure from the inline start (a SplitPane's first pane, a DataTable column's resize and drop, the chat's artifact pane). Popups ask the anchored-position behaviour for start and end sides and alignment, which resolve against the trigger's direction, and Tooltip, Popover, HoverCard and CircularButton's tooltip take those sides too. A glyph that points along the reading direction (a "next" chevron, a send arrow, an undo) mirrors through the ds-mirror-rtl class, which the icon stylesheet owns; a component that draws an icon a consumer names decides it with mirrorClass, from the DIRECTIONAL_ICONS list. A keyboard shortcut keeps its left-to-right order (⇧ ⌘ P), as printed on the keys. Some sides belong to the screen, not the text, and stay physical by design: a map's geography, a chart's axes and plot (time runs left to right on every chart, GanttChart included), a pointer's position (ImageCompare, ColorPicker's field, the anchored behaviour's pixels), a placement prop that names a screen edge (Drawer's left and right, where start and end follow the direction; Toast's corners; the chat's docks and floating card), and a pill slid by a measured offsetLeft. Each carries a ds-allow(physical) directive with its reason. scripts/validate-logical-properties.mjs fails the build on any other physical side, on a horizontal transform not multiplied by the sign, and on a directional glyph drawn without the mirror, and every story renders a second time with dir="rtl" on <html> in the story tests (render and axe), beside the right-to-left play tests in Foundations/Reading direction and the /labs/rtl bench, which the page-level accessibility check and the gesture stress check both cover.
Shared primitives, never redrawn. A few small things recur across many components, and each has exactly one drawing. The three form-control indicators are presentational companions of the real controls: CheckboxIndicator (Checkbox's box, with the one SVG check and minus, CheckIcon and MinusIcon, exported beside it), RadioIndicator (RadioButton's circle and dot) and SwitchIndicator (ToggleSwitch's track and thumb, whose check is the same SVG). Each is aria-hidden and driven by a checked prop, with its states keyed on its own modifier classes, so a host that is itself the control (SelectionCard, QuestionCard, InspectorToggleSwitch's bar, FilterBar's multi-select rows) draws the indicator and carries the role and state on its own root; a host that adds ds-control-host to that root lights the indicator on hover exactly as the standalone control does. The standalone controls ring their indicator on focus (2px --color-action-primary-bg, offset 2px); a host that is itself the target rings its own edge. The chosen mark, ChosenMark (exported from the Dropdown module), is the trailing check a listbox or menu row shows on its selected item: one glyph at --icon-size-500 in the action colour, drawn by Dropdown, Combobox, RichDropdown, TimePicker, ModelPicker, ThemePicker, DropdownMenu, ContextMenu, EntitySwitcher and FilterBar's single-select rows, rendered only on the selected row, whose aria-selected or aria-checked carries the state. A mark is not a control: a row whose items are picked independently shows the checkbox indicator instead. Text that is read but not seen takes the one ds-visually-hidden class from src/behaviors/visually-hidden.css; a hidden native input that must stay focusable keeps its own rule with the reason beside it (FileInput). A close button in a header is a compact tertiary CircularButton (Dialog, Drawer, CommandPalette; ThreadTabs' 24px tab close, Lightbox's media controls and AppSidebar's mobile drawer close, which is its collapse toggle doing a second job, are the three sanctioned exceptions, each with its own geometry), and a mutually exclusive set of pills is a SegmentedControl, never a hand-built radiogroup (ModelPicker's effort row). scripts/validate-shared-primitives.mjs fails the build on a second drawing of any of these: a font glyph standing in for a checkbox or radio, an indicator class written outside its owning module, a copied switch, a per-component check rule, or a second visually-hidden recipe; its SANCTIONED table is the list of deliberate exceptions. The component-consistency-audit skill is the review for what the validator cannot see.
A control reaches a form through name, whatever element it is. The controls that render a native <input> or <textarea> (Input, Textarea, NumberInput, DateInput, FileInput, Slider) put name on it and need nothing more. The controls below take name as their own prop and write what a form should read into <input type="hidden"> elements: the selection, never the search or draft text (Combobox, Dropdown, RichDropdown, TimePicker, TagInput, SelectionCard); the checked state as value, nothing while unchecked (Checkbox and CheckboxGroup, RadioButton and RadioGroup, ToggleSwitch); the joined code (PinInput); the current hex (ColorPicker). None submits while disabled, as a disabled native control contributes nothing. So new FormData(form) and a server action see each of them, while the role, the indicator and the keyboard behaviour stay on the element the user works. A hidden input takes no part in constraint validation, so required on one of these is announced and styled but never blocks a native submit. SegmentedControl, ToggleGroup, Rating and DatePicker take no name and submit nothing; a form reads them through their change callback. A control built on a native input or button also takes the native form attribute, and its hidden input carries it, so it can sit outside the form it submits to; a div-rooted control has no form prop and sits inside.
Surfaces by family. The same shape of rule holds the surfaces together: a component takes its family's treatment rather than its own. A choice offered as a row (SelectionCard's cards, QuestionCard's option rows) is a form control and wears Input's surface: --color-input-bg-primary behind a --border-025 --color-input-border-primary hairline at --radius-300, --color-input-border-hover on hover and --color-input-border-selected when picked. A card that holds content (Panel, Accordion, NotificationCenter, the foundations swatches) sits on --color-bg-container-primary behind --color-bg-container-border, hovering its header to --color-bg-container-secondary; the semi fill with a backdrop blur (--color-bg-container-primary-semi) is reserved for tiles that float over the ambient background (Card, Widget, PromptSuggestions). A row inside an overlay (a menu item, a listbox option, a command, a tree row) has no fill at rest, hovers and focuses on --color-action-passive-bg-hover at --radius-200, the inner step of its --radius-300 panel. The current item of a navigation list (AppSidebar, SidebarPanel, ThreadPanel, ThreadTabs, NotificationCenter's tabs) holds --color-action-passive-bg-active, one step past the hover wash so current and hovered never read the same. Disabled is opacity: 0.4 with cursor: not-allowed everywhere.
The chat cards are one family. Every card a reply can carry (InterruptCard, QuestionCard's rows and summary, ActionCard, ActionReadout, ToolCall, AgentPlan, SourceTrail, MessageCard, ArtifactCard, UsageCard, CodeDiff) is drawn to one recipe, so a thread of them reads as one set. The recipe's one home is src/behaviors/chat-card.css: its --ds-chat-card-* properties carry every value below, declared on the cards themselves so a theme scoped to part of a page reaches them, and a card reads the property, never the token behind it. A new chat card starts there: it joins the file's selector list, imports it, and reads the properties, and scripts/validate-chat-cards.mjs fails an ai component that draws a card shell of its own outside the family until it does (or is named, with its reason, as something other than a card). The rules, in the order a new card meets them:
- Shell.
--ds-chat-card-bg(--color-chat-card-bg, the crisp end of the neutral ramp, white in light and black in dark) behind--ds-chat-card-border(a--border-025--color-bg-container-borderhairline), at the column's full width. The corner is--ds-chat-card-radius(--radius-600, the chat bubbles'); a card whose content runs to its edges (CodeDiff's tinted rows, a CodeBlock standing on its own) takes--ds-chat-card-radius-edge(the code family's--radius-300), because a 24px curve cuts into an edge-to-edge row. - Inset. The first thing on every row stands
--ds-chat-card-inset(--padding-500) in from the card's edge. A title's or header's icon trails at the row's end (a status glyph beside the status word it stands for); only a list's markers, AgentPlan's steps and SourceTrail's rows, lead, on that same edge, stepped back--padding-050(the inset a status glyph draws its circle at) so they stand optically on the text edge above. A stacked card and an opened panel hold their first and last text--ds-chat-card-edge(--padding-300) off the top and bottom edges. - Type. A stacked card's title is
--font-title-body-*; a one-row label is--font-paragraph-sm-emphasis-*; a secondary line is--font-paragraph-sm-*. Every label is on--color-text-primary, a tool name and a filename included: monospace is for the code itself, scaled by--font-code-aspectso it reads level with the text. - Rhythm, three steps. A title's subline (a description, a detail, a meta or subtitle line) sits straight under it,
--ds-chat-card-gap-subline(none: the line's own leading is the space, the way ArtifactCard's kind sits under its name). A body under that (a description under a meta line, a list of facts, the call a question covers) stands--ds-chat-card-gap-text(--gap-200) off. Blocks (text to buttons, a body to its actions) are--ds-chat-card-gap-block(--gap-400) apart. - One-row cards. A closed disclosure header, an ActionCard record and CodeDiff's file row are
--ds-chat-card-row-height: a compact control and--ds-chat-card-row-pad(--padding-100) above and below, so a row holding Undo is as tall as one without. - Icons.
--ds-chat-card-icon(--icon-size-500) throughout, spinners in the same fixed box; ArtifactCard's mark is the one larger glyph, in its 40px tile, and its--padding-300is the one inset the family relaxes, for the tile's concentric corner. - No card inside a card. A framed component in a card's content area (a CodeBlock in a ToolCall, a ToolCall in an InterruptCard) sheds its frame and its rows take the parent's inset, the rule MessageCard's media slot follows for a Chart. A CodeBlock in a ToolCall also drops its header bar: the code sits straight on the card and Copy moves to the bottom-left footer place, at a compact secondary Button's size and outline like the card's other actions; a CodeBlock standing on its own keeps its header. A card has one footer, so when the parent's footer is taken (an InterruptCard's decision over the call it covers) the code does not bring one: it sits in a quiet well,
--color-bg-container-secondaryat--ds-chat-card-radius-edgewith no border (a slot, like MessageCard's media, not a card), with CodeBlock's own quiet Copy in the well's corner, and the opened call drops its divider. - Motion. An opening panel animates a clip with no box of its own, and its padding and divider live inside that clip in both states, so only the height moves.
- Button rows. A card's actions are compact Buttons in one bottom-left footer: the recommended choice primary, the others secondary, and in a row of three or more the last, the way out, secondary in the neutral tone (ActionCard's Dismiss, InterruptCard's last option).
- Decisions become records. A decision once made never leaves dead buttons: InterruptCard's answer is a read-out at a compact button's size and shape in the status it records, worded as the decision made ("Denied"), as is the "Confirmed" of an ActionCard that keeps its facts, and ActionCard's Undo is a compact tertiary Button behind the
undoglyph, the one borderless control a record carries. - Shared marks are drawn once. The read-out and the fact list (a label beside its value, a line each, as ActionReadout and ActionCard list a change) are classes in the recipe file,
ds-chat-card-readout(with its--positiveand--errorstatuses) andds-chat-card-facts, which every card that shows one composes beside its own part class, setting only where the mark sits.
Button
ds-button — The primary interactive element. Always on --radius-pill (a full pill in the base theme; a squared theme takes it to the input's corner, so a button and an input always match), and the label never wraps (white-space: nowrap, shared with AiButton) — a squeezed layout shortens around the button rather than folding the pill into two lines. Text uses --font-paragraph-emphasis-* (default) or --font-paragraph-sm-emphasis-* (compact). Icon size: 24px (default), 20px (compact). Transitions: background-color, border-color, and opacity at --motion-duration-base / --motion-ease-standard. A trailing icon (iconRight) nudges 2px right on hover at --motion-duration-fast / --motion-ease-entrance — a small confirmation that the button leads somewhere; never on disabled buttons. Disabled state: opacity: 0.4, cursor: not-allowed — never hidden. The text is label; when it is absent, plain-text children stand in for it (<Button>Save</Button> reads "Save"), with a development-only warning naming label as the documented prop, and anything richer than text is ignored, since icons ride iconLeft and iconRight. The warning comes from warnOnce in src/behaviors/devWarning.ts, the library's one development-warning helper, which a production bundle drops.
Two independent axes set the look. variant is the emphasis — primary (filled), secondary (outlined), tertiary (ghost) — and tone is the colour — default (the action colour), neutral (the greys), destructive (the danger colour). Each cell renders one modifier class:
variant × tone | Class | Fill | Border | Text |
|---|---|---|---|---|
| primary × default | ds-button--primary | --color-action-primary-bg | none | --color-action-primary-text |
| secondary × default | ds-button--secondary | transparent | --color-action-primary-border 1px | --color-text-primary |
| tertiary × default or neutral | ds-button--tertiary | --color-action-passive-bg (near-transparent) | none | --color-action-passive-text |
| primary × neutral | ds-button--neutral | --color-action-neutral-bg | none | --color-action-neutral-text |
| secondary × neutral | ds-button--secondary-neutral | transparent | --color-action-neutral-border 1px | --color-text-primary |
| primary × destructive | ds-button--primary-destructive | --color-action-destructive-bg | none | --color-action-destructive-text |
| secondary or tertiary × destructive | ds-button--destructive | transparent | --color-action-destructive-border 1px | --color-action-destructive-text-secondary |
Hover: primary and secondary → --color-action-primary-bg-hover; both neutral cells → --color-action-neutral-bg-hover with --color-action-neutral-text; outlined destructive → fills with --color-action-destructive-bg under --color-action-destructive-text; filled destructive → --color-action-destructive-bg-hover, then --color-action-destructive-bg-active when pressed. Active: primary and secondary → --color-action-primary-bg-active; both neutral cells → --color-action-neutral-bg-active. Hover and active walk away from the page — deepening in light mode, brightening in dark — so the pressed fill never sinks toward the floor behind it. The neutral tone is the quiet option: a button's full silhouette without the action colour's weight. Tertiary is already grey, so the neutral tone leaves it unchanged, and a tertiary destructive takes the outlined look, because a danger action needs a visible edge. The filled destructive is for the one irreversible action a surface leads with. The single-axis values from before the split stay as deprecated aliases: variant="neutral" is primary × neutral and variant="destructive" is secondary × destructive, which is why those two cells keep their original class names.
Sizes: default (padding 8px × 20px), compact (padding 6px × 12px).
A tertiary button never stands alone in an action row. Its transparent resting field has no visible edge, so a lone tertiary reads as misaligned text rather than as an action. An action row leads with a primary or secondary button, and tertiary follows it — nav lists and icon rows, where every entry is deliberately passive, are the exception, not action rows.
Loading state: loading puts a tone="inherit" Spinner in the left icon slot (24px default / 16px compact), keeps the variant's full-colour appearance (no disabled dim), blocks interaction (pointer-events: none, cursor: progress, disabled attribute), and sets aria-busy. A loading href button renders the <button> branch so the link is truly inert.
ButtonGroup
ds-button-group — Inline container that composes Buttons for nav and subnav contexts. Two orientations: horizontal (default — --gap-500 between buttons, used in top navigation) and vertical (--gap-050, left-aligned hug-content column for subnav/mobile). Purely compositional: each entry is a full ButtonProps config passed straight through to Button, so variants and disabled states mix freely — except that an entry with no variant defaults to tertiary, not Button's own primary, so nav groups read as passive by default. role="group", named through the native aria-label (ariaLabel is its deprecated alias).
CircularButton
ds-circular-button — Round icon-only button, 40px (default) or 32px (compact), always --radius-pill. The same two axes as Button — variant for the emphasis, primary (--color-action-primary-bg fill), secondary (outlined, --border-025 + action border tokens) or tertiary (ghost, --color-action-passive-bg-hover on hover), and tone for the colour, default, neutral or destructive: primary × neutral is the solid --color-action-neutral-bg fill (ds-circular-button--neutral, always shown at rest), secondary × neutral the --color-action-neutral-border outline filling with the neutral greys on hover and press (ds-circular-button--secondary-neutral) and the standing look of an outlined utility control such as a toolbar action or a notification bell, tertiary is unchanged by the neutral tone, and the destructive tone follows Button: primary × destructive is the filled circle (ds-circular-button--primary-destructive, the --color-action-destructive-bg steps), secondary and tertiary the outlined danger look (ds-circular-button--destructive: --color-action-destructive-border and --color-action-destructive-text-secondary, filling with --color-action-destructive-bg on hover and press). The legacy variant="neutral" stays as a deprecated alias for primary × neutral. Same state set (default/hover/active/disabled). Icon is a single Material Symbol at 24px default / 20px compact. Renders as <a> when href is set. ariaLabel is required — there is no visible label. Follows the published API contract like Button: disabled is the real boolean (state stays as the documentation-only affordance), the ref forwards to whichever element renders, and unrecognised props — data-*, aria-*, native event handlers — spread onto it. loading swaps the icon for a tone="inherit" Spinner, keeps full-colour appearance, blocks interaction, and sets aria-busy — same contract as Button. Self-labelling tooltip: because there is no visible label, the button wears a Tooltip carrying ariaLabel by default, on hover and focus — the same convention MessageActions established for its icon row. tooltip accepts a string for different wording or false to opt out (a host that labels the control another way, or owns the button's box directly — SplitButton's trigger); tooltipPosition picks the side, for hosts whose edge would clip the default top placement (the chat panel's header opens its tooltips downward).
SegmentedControl
ds-segmented-control — Horizontal set of mutually exclusive options on a --color-bg-container-primary track with a --radius-pill pill silhouette. The active segment takes --color-action-primary-bg with --color-action-primary-text, drawn by a single sliding pill: one absolutely positioned indicator element slides and resizes between segments (transform + width at --motion-duration-base / --motion-ease-entrance), its box written from the active segment's measurements on every change and re-measured without animation on resize. A move that lands before the pill's first painted frame snaps rather than slides, so a post-hydration correction (the header theme toggle hydrates on the server's segment, then jumps to the visitor's) never animates across the control on page load. Until the first client-side measurement lands (SSR, first paint) the active segment paints its own background, so the selection is never missing; once the pill takes over, hovering the active segment darkens the pill itself (--color-action-primary-bg-hover). Idle segments use --font-paragraph-emphasis-* in --color-text-secondary with --color-action-passive-bg-hover on hover, and an idle segment's icon shares the label's --color-text-secondary — never a dimmer icon tier — so icon-only segments keep label-strength contrast. tone="neutral" swaps the active fill to --color-action-neutral-bg/--color-action-neutral-text, for sets where the selection should not carry the action selection convention's weight (the deprecated variant="neutral" still works). Segments accept an optional Material Symbol icon and per-segment disabled. The track carries a --border-025 --color-bg-container-border hairline, and its corner is concentric with the pill: --radius-pill plus the track padding plus that hairline, so a theme that squares the pill token keeps the two curves parallel. Sizes: default, compact; fullWidth stretches segments across the container. collapse makes the strip shed parts rather than overflow when its container is too narrow for it: the control measures its own three natural widths once from the full render, against its host's content box (the host's padding is room it cannot use) — everything, labels alone, icons alone — and picks the widest that fits on every later resize, so the ladder never oscillates and a container that grows back re-reads the same numbers. Decoration goes before meaning: the icon is shed first and the label last, it only falls to icons when every segment has one, and a hidden label is clipped rather than removed, so the segment keeps its accessible name. Off by default — the control overflows as it always did. AgentRail is the first consumer, where it is what makes four tabs fit a 360px rail. showLabels={false} asks for the icon rung outright, for a host that knows it is in a small mode rather than measuring for it (the playground's bottom bar on a compact stage), and makes the strip an icon strip: each label becomes its segment's tooltip on hover and keyboard focus (Tooltip's surface, after the same hover-intent delay), placed by the shared anchored-position behaviour so a clipping seat such as a sidebar's floor cannot cut it off, painted in the top layer like every tooltip, and stays the segment's name; the segments square their padding around the glyph, and a segment with no icon keeps its label on screen. A strip the collapse ladder squeezes to icons keeps its labels clipped instead, with no tooltip. Announces as a tablist (role="tablist", segments are role="tab" with aria-selected, and a segment's panelId writes aria-controls). Keyboard: Arrow keys cycle enabled segments, Home/End jump to the ends. The selection follows the controlled/uncontrolled pair convention (value/defaultValue/onValueChange, with activeSegment and onSegmentChange kept as deprecated aliases). cycle draws one CircularButton in place of the strip, for a seat with room for a single control (a collapsed rail's foot, a condensed header's actions): it shows the current segment's glyph (a segment without an icon shows its label's first character), each press steps to the next enabled segment, round again, the current label is its tooltip, and its accessible name is the control's aria-label with both labels ("View: List. Switch to Grid"), or whatever cycleLabel returns from the two. The circle wears the track recipe the CommandPaletteTrigger and the ThemePicker's trigger wear, hovering as they do: the fill steps to --color-bg-container-secondary and the glyph holds --color-action-icon-default. The ref and the spread land on the circle's wrapper, and the same size applies; tone has nothing to colour here, since the circle wears the one recipe. Any exclusive set can take the form, so a control that collapses with its host (AppSidebar's collapsedFooterSlot) steps through its options rather than vanishing.
ThemeToggle
ds-theme-toggle — The colour-mode control: System, Light and Dark (the routine, light_mode and dark_mode glyphs) on a SegmentedControl, which draws every pixel, so it reads as the system's one mutually exclusive set of pills; the root class only keeps the strip at its own width in a squeezed row. default by default, the height of the CommandPaletteTrigger and AiButton it sits beside in a top bar, so a row of the three needs no size props, with compact for a denser row; showLabels={false} makes it SegmentedControl's icon strip, each label its segment's tooltip and still its name, and the three labels and the strip's name (aria-label, "Colour mode") are props with English defaults. cycle is SegmentedControl's cycle form, one compact or default circle in place of the strip, for a row short of room (a condensed SiteHeader's condensedActions, a collapsed AppSidebar's footer): it shows the current setting's glyph, each press steps to the next (System, Light, Dark, round again), the current label is its tooltip and its accessible name is the control's aria-label with both labels ("Colour mode: Light. Switch to Dark"), or whatever cycleLabel returns from the two; the circle's recipe and hover are the strip's spec's. A host that owns colour mode itself (its own settings store, or a library such as next-themes) passes value and hears picks through onValueChange, applying them itself; left uncontrolled, defaultValue is the setting a first visit takes, and a stored choice always wins. It runs on useColorMode (useColorMode.tsx beside it), the setting and its setter: two attributes on <html> carry the mode, data-theme-setting (the choice) and data-theme (what it resolves to, which every token reads), every instance subscribes to the attribute, so no two controls disagree, across tabs too, and the choice is stored under storageKey (default theme). colorModeScript() (colorMode.tsx, safe to import from a Server Component) returns the script that applies the stored choice before first paint and keeps system following the operating system; render it in the document head. It also puts the choice back if React wipes it: a hydration failure at the root makes React client-render <html> and strip every attribute on it, so the script watches for data-theme-setting going missing and applies the stored choice again. The documentation site's header toggle and its pre-paint script are these, so the site and a consumer's app cannot apply the mode differently.
ThemePicker
ds-theme-picker — The theme switcher: a circular trigger the row height across (--control-height-*), on the track recipe the theme toggle and the command palette trigger wear (--color-bg-container-primary behind a --border-025 --color-bg-container-border hairline, SegmentedControl's track curve), wearing the active theme's swatch as a chip on the icon scale, in the theme's own colour and corner. It opens a menu of every theme on offer, DropdownMenu's surface, each row a menuitemradio previewing the theme's swatch, its name in its heading face and a line in its body face, the chosen one holding the passive wash with the shared ChosenMark. The host supplies themes and applies the choice through onValueChange (a data-brand attribute for the shipped presets), so the picker is the control and never the theme engine; value and open follow the pair convention, and disabled holds the trigger at the library's disabled treatment (opacity: 0.4, cursor: not-allowed) with the menu shut. The menu is placed by the shared anchored-position behaviour, preferring placement and lining up with the trigger's align edge, and dismisses through the layer stack, Escape returning focus to the trigger; the arrow keys, Home and End move through the rows and into an optional footer, which sits after the themes below a divider (the site puts a link to the playground there). The trigger's and the menu's names are triggerLabel and menuLabel. The trigger is a bare swatch, so it carries a Tooltip of its accessible name (triggerLabel), opening on the side the menu opens to and hidden while the menu is open.
ToggleGroup
ds-toggle-group — A row of pressable buttons sharing a bordered --radius-300 container (--border-025 on --color-bg-container-border, filled --color-bg-container-primary like SegmentedControl's track, so the group reads as one control); items hover on --color-action-passive-bg-hover. Single-select by default (pressing the active item keeps it active); multiple allows any combination. Active items fill with --color-action-primary-bg and --color-action-primary-text (tone="neutral", or the deprecated variant="neutral", swaps them to the --color-action-neutral-* greys); items can be text labels or Material Symbol icons (icon: true). Sizes: default, compact. Group-level disabled applies the standard opacity: 0.4 and cursor: not-allowed treatment. role="group" + aria-pressed per item. The selection follows the controlled/uncontrolled pair convention (value/defaultValue/onValueChange), a string when single and an array when multiple.
SplitButton
ds-split-button — A primary action with an attached menu of alternatives ("Save" beside "Save as draft"), composed from Button, CircularButton and DropdownMenu inside one pill silhouette. The outer corners stay --radius-pill and the meeting edges square to --radius-050; the main segment's inner-edge padding tightens one step (--padding-300 default, --padding-200 compact), since the squared edge has no pill cap to clear; and the chevron is optically centred, nudged 2px (1px compact) toward the flat edge, because the flat-plus-round-cap shape carries its visual mass left of the geometric centre. The split is drawn per variant: on primary emphasis a --border-025-wide gap of bare page between the filled segments, on secondary emphasis the gap collapses and the trigger drops its leading border, so the main segment's own outline is the single divider line rather than two hairlines side by side. Like Button, variant sets the emphasis (primary or secondary) and tone the colour (default, neutral or destructive), passed to both segments so they always match; the seam follows the emphasis, so a filled destructive split keeps the gap and an outlined one collapses it; the legacy variant="neutral" is a deprecated alias for primary × neutral. Sizes: default, compact. The label segment fires onClick; the chevron segment is a CircularButton (accessible name from menuLabel) opening a DropdownMenu with the standard entry tree, keyboard behaviour, and align options. loading runs Button's loading contract on the label segment and holds the trigger inert at full colour with a progress cursor — the loading contract's no-dim appearance, because a disabled dim on one half would split the pill into two unrelated controls; disabled takes both segments down together, and when both flags are set the disabled dim wins across the whole control, matching Button's own precedence.
Heading
ds-heading — A heading in the type scale. level (1 to 6, default 2) sets the element and tier sets the look, chosen apart so the outline stays correct whatever size a design calls for: mega-1, mega-2, display-1, display-2 and sub-display read their --font-<tier>-* tokens in --color-text-display, heading-1 to heading-3 in --color-text-heading, and title-body (the body face's title, the default for levels 4 to 6) in --color-text-heading too, so a theme that tones the heading tier tones every Heading. tone="neutral" takes --color-text-secondary for a quieter title. truncate keeps it to one line with an ellipsis, balance sets text-wrap: balance, and align takes the logical start, center or end. No margin of its own: spacing belongs to the layout around it. Presentational and server-renderable.
Text
ds-text — Running text in a tier of the type scale: paragraph (the default), paragraph-sm, caption, overline (uppercase, for an eyebrow) and code (the code face at the small paragraph size). weight="emphasis" takes the tier's emphasis weight, the paragraph tiers through their own emphasis styles and the small tiers through paragraph-sm's. as picks the element (p by default, span, div, label with htmlFor, strong, em). tone is the primary text by default, neutral the secondary, one of positive, warning and error for a message of that kind (their --color-status-*-text roles), or inherit. truncate ellipsises one line, lines clamps to a count, numeric sets tabular figures so a column of amounts lines up, and align takes the logical sides. No margin of its own. Presentational and server-renderable.
Link
ds-link — A text link. inline (the default) takes the sentence's own face and colour, underlined, so it reads as part of the prose; standalone takes --color-action-primary-text-tertiary, the tertiary Button's text, in the paragraph emphasis face (size="compact" the small one). Both keep the underline at rest, a hairline from the font set 0.2em below the text, and thicken it to --border-050 on hover, so a link reads as one whatever colour a theme gives its text; neither takes the action fill. external opens a new tab with rel="noopener noreferrer", adds an arrow_outward mark and reads externalLabel ("(opens in a new tab)") from a visually hidden span; an external link renders a plain <a>, every other through linkComponent ?? 'a'. disabled renders a <span role="link" aria-disabled="true"> at the library's disabled treatment, with no address. iconLeft/iconRight take a Material Symbol name or a node, sized to the text (--icon-size: 1em, sanctioned in place, since an inline link can sit in any tier), and a directional glyph mirrors in a right-to-left context. Server-renderable.
List
ds-list — A list of items: unordered (a ul), ordered (an ol, with start and reversed) or plain (no markers, no indent). marker defaults to the variant's own (disc, number, none) and also takes check, which draws the form controls' CheckIcon in --color-status-positive-text, or any Material Symbol name, drawn by the icon font in a ::before from a custom property. Every marker sits in one gutter at the inline start, --icon-size-500 plus --gap-200, in a box one line of the item's text tall (1lh), so it centres on the first line however the label wraps; native discs and numbers take --color-text-secondary through ::marker. ListItem takes a description, a quieter line in the small paragraph face under the label, and an icon that replaces its marker. spacing sets the gap between items (--gap-050, --gap-200, --gap-400) and size the type (paragraph or paragraph-sm). Icons are decoration, aria-hidden: the text carries the meaning. Presentational and server-renderable.
Deadline
ds-deadline — A due date against now: "Due in 2 days", from prefixLabel, then the distance through formatRelative in the resolved locale. Its status comes from the distance, never a prop: neutral, then the warning role under thresholds.warning (three days by default), the error role under thresholds.critical (one day), and the error role with overdueLabel ("Overdue") once it passes; onStatusChange reports each change, and deadlineStatus, exported beside it, applies the same rule for a host's sort or filter. variant="badge" draws the Badge family's tinted pill (--color-status-*-bg and -text, --radius-100, Badge's inset and type); "text" writes it inline in the status text colour for a sentence or a cell. display shows the distance, the date and time (month, day, hour, minute, and the zone's short name when timeZone is set: "Nov 7, 4:00 PM PST"), or both. The full date sits in a <time dateTime> with the long form as its title. It rewrites itself every MOTION_DEADLINE_TICK_MS while the page shows and stops while it is hidden (tick={false} holds it still); now fixes the moment it measures from, for a server render, a test or a screenshot, and stops the tick.
DocumentViewer
ds-document-viewer — A document on the page: a frame on --color-bg-container-secondary behind a --color-bg-container-border hairline at --radius-300, a toolbar on the page colour behind a --color-divider rule (the title in the small paragraph emphasis face, ellipsised, then toolbar, open in a new tab and download as compact tertiary CircularButtons), then the file, twelve control rows tall (tall, eighteen; fill, its container's height). kind="auto" reads the kind from fileName (else the end of src) and mimeType through the attachment family's getFileKind. A PDF shows in the browser's own viewer through an <object> named by the title, whose children are the fallback, so a browser that will not embed it shows that instead; the library carries no document renderer. A page with a Content Security Policy must allow the PDF's origin in object-src (or default-src); a blob: or data: address is blocked under 'self' and shows the fallback. An image sets in a scrolling region named by the title, at a width zoomed through six steps (the reading is a button that fits it to the width again, named by resetZoomLabel). A text file is fetched and set in the code face in the same scrolling region. The frame itself stays unnamed, so the scrolling region is the one landmark. The default fallback says fallbackLabel and offers the download. download (a string names the saved file) and openInNewTab show or hide their buttons, and onDownload replaces the browser's download for a file the host must fetch with credentials. bare drops the frame and the toolbar and fills its host, for a host with a header of its own: AttachmentViewer shows a file with a fileUrl this way, under its own name, download and close, so a document in the chat's file viewer has one header, not two.
Badge
ds-badge — Inline status label. Radius --radius-100 (4px) — notably tighter than buttons and inputs. Text: --font-paragraph-sm-emphasis-* (14px/500). Padding: 2px vertical × 8px horizontal. Each of the five status variants (info, positive, warning, error, neutral) maps directly to its --color-status-*-bg and --color-status-*-text tokens. Badges are borderless by decision (2026-08-26): the tinted fill carries the status on its own, and the old per-variant borders sat below 3:1 against their fills — a transparent --border-025 border remains for stable geometry. Renders with role="status" for accessibility.
Chip
ds-chip — Compact pill for attributes, filters, and inline metadata. Always on --radius-pill, like Button. Neutral by default: --color-bg-page-primary fill, --color-bg-container-border hairline, --color-text-secondary text in --font-paragraph-sm-emphasis-* (14px/500). Icon size: 24px (large), 20px (default), 16px (compact) — below large, smaller than Button's because the chip is a tighter control. removeLabel also takes a function of the chip's label text, so each chip in a group gets its own name.
Three sizes, matching the Button ladder one rung down: compact (24px tall), default (32px), and large (40px). large is the exception to "tighter than a Button" — it takes the default Button's --padding-200/--padding-500 box, --font-paragraph-emphasis-* (16px/500) text, and 24px icons, so a pill that is a primary tap target rather than metadata reads at the same weight as the body text around it. PromptSuggestions is its intended consumer.
A chip never wraps and is never wider than its container: a label too long for the room ends in an ellipsis (.ds-chip__label), giving way before the icon or the remove button, and its full words stay its accessible name. Suggestion chips are held to a budget that fits a phone, so the ellipsis is the net, not the plan.
Distinct from Badge: a Badge communicates status through the five status colours and is never interactive; a Chip is neutral and optionally interactive.
A selected chip takes the action selection fill (--color-action-primary-bg under --color-action-primary-text); tone="neutral" moves it to the --color-action-neutral-* greys (ds-chip--selected-neutral), for filter rows whose selection should read quietly, the call SegmentedControl's neutral tone makes. Renders as a <span> when non-clickable, a <button> when given onClick (with aria-pressed when selected is set). onRemove adds a trailing close button (Material Symbol close) with its own accessible label; when a chip is both clickable and removable the container stays a <span> and the main region becomes an inner button so buttons never nest.
States:
- Hover (clickable):
--color-action-passive-bg-hoverfill; Active:--color-action-passive-bg-active - Selected:
--color-action-primary-bgfill with--color-action-primary-text— same action selection convention as SegmentedControl - Disabled:
opacity: 0.4,cursor: not-allowed - Focus: standard 2px
--color-action-primary-bgfocus ring
Sizes: large (the default Button box, 40px tall), default (padding 6px × 12px — 32px tall), compact (padding 2px × 8px — 24px tall).
Alert
ds-alert — Container for inline status messages. Same five variants as Badge. Wider form — carries an icon, title, and description block. References same --color-status-* tokens. Use when message needs to persist in the layout (vs Toast which is ephemeral). Sizes: default, and compact, which tightens the padding and sets the title and description in --font-paragraph-sm-* with a smaller icon. dismissible adds a close control at the trailing edge (named by dismissLabel); it only reports the click through onDismiss, and removing the alert is the consumer's render decision, the same as Banner.
ErrorSummary
ds-error-summary — Every error on a form at the top of it, on the Alert's error surface (--color-status-error-bg behind a --color-status-error-border hairline at --radius-200 with --padding-400, the error glyph in the border colour, the text in --color-status-error-text). A <section> named by its heading, an h2 by default (headingLevel moves it), whose text titleLabel writes from the count in the resolved locale ("There is a problem", "There are 2 problems") unless title replaces it; an optional description under it; then a list with one underlined link per error. A link is an in-page #id jump the component handles itself (sanctioned in the contract validator): it scrolls the control's Field, or the control, to the centre so its label shows, then focuses the control without a second scroll, and reports it through onErrorClick. With focusOnMount (the default) the summary takes focus (tabIndex={-1}) when its list goes from empty to holding errors, so a screen reader reads it on submit and a keyboard user starts from it; it renders nothing while the list is empty. fieldErrorsFrom(record, { order, idFor }), exported beside it, turns the common server shape { field: message | message[] } into the errors list and a per-field errorText map for Field and Input.
Banner
ds-banner — Full-width page-level status strip: the third sibling in the feedback family. Alert sits inline in the content, Toast interrupts and leaves, Banner spans the page and stays until dismissed. Same five status variants on the --color-status-*-bg/-text pairs, square-cornered and edge-to-edge by design — it belongs to the page frame, not the content column. One --font-paragraph-sm-* line with an emphasised leading title in the -em weight, a per-variant Material Symbol (neutral takes campaign, the announcement horn, rather than Alert's info), an action slot for a compact Button, and an optional dismiss control; align="center" centres the content for short environment notices. Stateless like Alert: dismissible shows the close control, onDismiss reports the click, and removal is the consumer's render decision. role="status". The dismiss button is named by dismissLabel.
Input / Textarea
ds-input — Label + field + helper text stack with 8px (--gap-200) vertical gap. Field: --radius-300, --color-input-bg-primary fill, --color-input-border-primary hairline. Label: --font-paragraph-emphasis-*. Body: --font-paragraph-*. Helper: --font-paragraph-sm-* in --color-text-tertiary.
Affixes: prefix and suffix put text or a node inside the field before and after the value (a scheme, a currency symbol, a unit) in --font-paragraph-* --color-text-secondary; unlike the decorative icons they are read as part of the field, through aria-describedby. clearable adds a compact tertiary CircularButton (clearLabel, "Clear") while the field holds a value, which empties it through a native input event (so onChange and onValueChange fire with an empty value, controlled or not), calls onClear and hands focus back. With any of these the field's box moves from the <input> to its wrapper (ds-input--affixed): the wrapper takes the border, fill, hover, :focus-within, error and disabled treatments, and the input becomes a bare text run that grows between the affixes, so an affix of any width sits on the line with no padding to guess. action adds a control at the field's end, after the suffix (MaskedInput's show and hide button), which is a control of its own rather than part of the field's description. An input without affixes is unchanged. errorText is Field's newer error form, passed through.
States:
- Default:
--color-input-border-primary - Hover:
--color-input-border-hover(a mid step of the action colour's ramp, clearing 3:1 against each page) - Focus:
--color-input-border-selected(the active border, on the action colour's weight) - Error:
--color-status-error-borderon border and helper text - Disabled:
--color-input-bg-disabledfill,--color-input-border-disabled,--color-input-text-disabledon label/text
A field's resting hairline is deliberately quiet, since the field's surface already marks it out. A checkbox or radio is only its ring, so its unchecked state takes --color-input-indicator-border, a stronger neutral that clears 3:1 against --color-input-bg-primary, so an enabled control never reads as disabled.
Icon slots (Material Symbols Rounded): left icon at 16px from edge, right icon at 16px from edge. Compact size: padding 6px × 12px, icon 20px.
The system draws errors; it never decides them. When to validate and the form's state are orchestration, and they belong to the consumer's form layer, not to a component library. What the library gives that layer is the surface to show the result: every form control carries an error vocabulary (an error flag that marks the control invalid, plus a status border wherever the control has one), Field, Input, CurrencyInput, MaskedInput and SignaturePad take the newer errorText, the message under the control with the helper text kept beside it, and ErrorSummary lists every error at the top of the form, with fieldErrorsFrom turning a server's { field: message } record into both.
Checkbox
ds-checkbox — Custom checkbox with an inline SVG check (and minus for indeterminate) instead of font glyphs, for pixel-perfect centring; the glyphs are CheckIcon and MinusIcon, exported beside the component as the one home of the check every control draws. The box is CheckboxIndicator, exported beside the component for a host that is itself the control (see the Components intro): always a rounded square, --radius-200 held between 4px and 8px so a theme's radius never sharpens it or rounds it towards a radio, with a --border-050 --color-input-indicator-border ring on a --color-input-bg-primary fill, Input's field surface, so it stands off a container as well as the page; checked state fills --color-action-primary-bg with --color-action-primary-text-active stroke, hover shifts to --color-action-primary-bg-hover (through the ds-control-host class, so a host's hover lights it the same way), and focus rings the box (2px --color-action-primary-bg, offset 2px). Sizes: default, compact. Disabled: opacity: 0.4, cursor: not-allowed. helperText renders a --font-paragraph-sm-* line under the label (stacked in a text column beside the box, the box aligned to the first line); error recolours it to --color-status-error-border and sets aria-invalid, both announced via aria-describedby. A CheckboxGroup wrapper renders a labelled list of checkboxes bound to a value array — it composes <Field group>, so the group label, required marker, helper/error line and the aria-labelledby/aria-describedby wiring are Field's, with group-level error carrying aria-invalid on the role="group" node. checked follows the controlled/uncontrolled pair convention (checked/defaultChecked/onCheckedChange), and CheckboxGroup's value array does too (values/defaultValues/onValuesChange). name submits value (default on) through a hidden input while checked and nothing otherwise, as a native checkbox does; CheckboxGroup's name reaches every item, so each checked value submits under it.
RadioButton
ds-radio — Single-select control: a circle at every theme radius (its ring and dot are drawn at 50%, never --radius-pill, which a squared theme turns square) with a --border-050 --color-input-indicator-border ring on a --color-input-bg-primary fill (Input's field surface, so it stands off a container as well as the page) and an inner dot that fills --color-action-primary-bg when checked; hover shifts the ring to --color-action-primary-bg and focus rings the circle with Checkbox's 2px --color-action-primary-bg ring. The circle is RadioIndicator, exported beside the component for a host that is itself the control (see the Components intro). Implemented as a role="radio" element with aria-checked, Space/Enter activation, and managed tabIndex; grouping is React state through RadioGroup (value/onValueChange); name submits the checked option's value through one hidden input, so the group reaches a form as a native radio set does while grouping itself stays React state. Label uses --font-paragraph-*; helperText adds a --font-paragraph-sm-* line under it, announced via aria-describedby — error recolours it, deliberately without aria-invalid, which ARIA does not allow on role="radio"; the group form carries it instead. RadioGroup composes <Field group> (label, required marker, helper/error, aria-labelledby/aria-describedby/aria-invalid on the role="radiogroup" node). Disabled: opacity: 0.4. RadioGroup follows the controlled/uncontrolled pair convention (value/defaultValue/onValueChange); a lone RadioButton is fully controlled by design, because its checked state belongs to the group (an uncontrolled radio could only ever turn on).
ToggleSwitch
ds-toggle-switch — Binary on/off switch: a 42×24px --radius-pill track filled with --color-action-primary-bg when on, holding a 20px --color-control-thumb circle that slides with a spring-curve transition and shows the same SVG check as Checkbox. The track is SwitchIndicator, exported beside the component for a host that is itself the control (see the Components intro); focus rings the track, not the label. Rendered as a <button role="switch" aria-checked>; the optional inline label uses --font-paragraph-*, and helperText stacks a --font-paragraph-sm-* line under it (announced via aria-describedby; error recolours it and sets aria-invalid). Sizes: default, compact. Used for binary settings — an on/off preference with immediate effect. (The site's own theme control outgrew it: three states made it a SegmentedControl.) SelectionCard's toggle mode and InspectorToggleSwitch's bar draw this exact anatomy through SwitchIndicator. checked follows the controlled/uncontrolled pair convention (checked/defaultChecked/onCheckedChange); uncontrolled, the switch starts on unless defaultChecked is false. name submits value (default on) through a hidden input while on, nothing while off.
Slider
ds-slider — Range input styled with a two-tone track: --color-action-primary-bg fill up to the current value, --color-bg-container-tertiary beyond it, with a --radius-pill thumb that gains a --color-action-primary-bg-hover focus ring. Native <input type="range"> underneath, so keyboard and screen-reader behaviour come for free (min/max/step props). Composes inside Field: label (a real <label htmlFor> — the range input is labelable), helperText, error, required, and showValue, which prints the live value in Field's aside slot opposite the helper; with any of them present the root stacks as a column (--fielded), and a bare slider keeps its original row untouched. Unlabelled sliders fall back to an aria-label (native attribute first, then the deprecated ariaLabel prop, then "Slider"). Sizes: default, compact. Disabled: opacity: 0.4. The value follows the controlled/uncontrolled pair (value/defaultValue/onValueChange), starting at 50 when uncontrolled.
InspectorSection / InspectorSlider / InspectorInput / InspectorToggleSwitch / InspectorSegmentedControl / InspectorDropdown / InspectorColorPicker
The Inspector family — one-row controls for tool panels and settings rails (the playground's rails are the first consumer). Each control is one bar with its name inside on the left and its state on the right, so a setting costs one row with no label above it. They are counterparts, not replacements: Slider, Input, ToggleSwitch, SegmentedControl, Dropdown and ColorPicker stay the controls for forms, where a label above and helper text below earn their room; an Inspector control is for a dense panel of many settings. The family lives in one shared folder, src/components/Inspector/, with one stylesheet (Inspector.css) every member imports, the way the chart set shares Chart.css.
ds-inspector-bar — the shared surface. Shaped and sized like Button, not Input: corners on --radius-pill, so the theme's pill and radius levers reshape the bars with every button, and a size prop matching Button's whose height, side inset and type are computed from Button's own tokens, so a bar and a Button of one size sit level: default is --padding-200 twice plus --font-paragraph-emphasis-line-height (40px) with --padding-500 sides and --font-paragraph-emphasis-* type; compact (ds-inspector-bar--compact) is --padding-150 twice plus --font-paragraph-sm-emphasis-line-height (32px) with --padding-300 sides and --font-paragraph-sm-emphasis-*. The name takes the emphasis weight in --color-text-primary; the reading takes the regular weight in --color-text-secondary. Colour climbs the neutral Button's ladder one step per state, which holds in both themes: the bar rests on --color-action-passive-bg-hover, a hovered bar and a slider's fill take --color-action-neutral-bg-hover, and a pressed one --color-action-neutral-bg-active. The action colour appears where the library's own controls put it, and nowhere else: the focus ring (the system's 2px --color-action-primary-bg outline, outside the bar), an on switch (ToggleSwitch's own track) and a selected segment (SegmentedControl's sliding pill), so a setting reads the same in a panel as in a form. A slider's fill and a hovered or pressed bar stay on the neutral ladder. Disabled is the system's 0.4 opacity and not-allowed on the whole bar. Wherever the platform has the control, it is the platform's: a native range, text field, checkbox or radio sits under or over the paint, so pointer, keyboard, form submission and screen-reader semantics come for free.
ds-inspector-slider — the whole bar is the slider and its fill (__fill) is the value. A native range input covers the bar at zero opacity, its thumb drawn --border-050 wide so the value-to-position mapping stays within a pixel of the painted fill, so dragging anywhere or clicking to jump both work. A short __grip line in --color-text-secondary sits just inside the fill's edge, held --padding-200 off both ends so it never rides the rounded corners; hover darkens the fill a step and turns the grip --color-text-primary, and holding it stands the grip taller with a grabbing cursor. The reading shows the step's precision (0.02 reads "0.50") unless format says otherwise, and the same string is the range's aria-valuetext. The fill position reaches the stylesheet through --ds-inspector-pct, never an inline declaration.
ds-inspector-input — the name is the field's real <label> (a click on it focuses the field), and the field takes the rest of the bar with its text right-aligned and its placeholder in --color-text-tertiary. The field is width: 0; flex: 1, so it never asks for its intrinsic character width and push its container. Hover and focus both lift the bar a step.
ds-inspector-toggle-switch — the whole bar is the switch: a native checkbox with role="switch" covers it, and ToggleSwitch's own track and thumb sit at the bar's end (__switch, drawn by ToggleSwitch.css), so the on state, the check in the thumb and the spring all match the form switch: the default switch in a default bar, the compact one in a compact bar. The component mirrors the checked state (the prop when controlled, the last change when not), because ToggleSwitch's paint keys off its --off class.
ds-inspector-segmented-control — the name on the left and the options as __segment chips in the bar's end, the bar's right inset shrunk to --padding-100 so the chips sit inset rather than touching the edge. Chips share the bar's --radius-pill shape; idle chips are the reading's colour and weight and hover takes --color-action-passive-bg-active. The selection is SegmentedControl's: --color-action-primary-bg under --color-action-primary-text at the name's weight, darkening to -hover under the pointer, drawn as one __pill that slides between chips on --motion-duration-base / --motion-ease-entrance. The component mirrors the selection and measures the selected chip (again on resize, since a web font can change the widths), and until that first measurement lands the chip paints its own fill, so the server render is already right. Each option is a native radio (one radiogroup named by the bar's label, arrow keys moving the selection, per-option disabled), and focus rings the chip rather than the whole bar.
ds-inspector-dropdown — the library Dropdown with its trigger dressed as a bar, so the menu, its keyboard model and its per-option font previews (font on an option) are Dropdown's own, and the selected option's face carries into the bar. The name is an absolutely positioned overlay that passes clicks through, and the trigger reserves room for it through --ds-inspector-label-width, measured (with a ResizeObserver, since a web font can change a name's width after load) rather than guessed. The Dropdown is named by the same words through aria-label. It passes value/defaultValue/onValueChange through to Dropdown, so it works controlled or uncontrolled.
ds-inspector-color-picker — the library ColorPicker with its trigger dressed as a bar, so the panel (saturation area, hue and optional alpha sliders, hex field), its keyboard model and its controlled/uncontrolled contract are ColorPicker's own. The trigger fills the bar and right-aligns its contents: the hex reading in the reading's colour and weight, then a round swatch one line tall (--ds-inspector-line-height, --radius-pill) whose end inset is computed to centre it in the bar, so it sits concentric with the pill's end cap. The name is an overlay that passes clicks through, and needs no measured reservation the way the dropdown's does, since the reading sits at the far end. The trigger is named by aria-labelledby pointing at the name and then at the hex reading (ColorPicker gives the reading an id for exactly this), so it reads as the setting followed by its value ("Tint colour #163300"). The panel opens under the bar and spans its width. Disabled dims the whole bar, name included.
ds-inspector-section — a collapsible, titled run of controls: a full-width header button (--font-paragraph-emphasis-*, an expand_more chevron at --icon-size-500 turning 180 degrees) over a body that stacks its controls --gap-200 apart, and stacked sections are divided by a --border-025 --color-divider hairline (none under the last). The header's --padding-300 sits above and below the title, and the body's foot is sized so the last control sits the same distance from the divider. The body opens through grid-template-rows: 0fr to 1fr on --motion-duration-slow / --motion-ease-emphasized, in a minmax(0, 1fr) column so a wide control can never push the panel. Two behaviours set it apart from Accordion: the body clips only while closed or animating and goes overflow: visible once settled open (--settled), so a Dropdown menu inside can open past the section's edge; and a closed body is inert, so its controls leave the tab order. open / onOpenChange control it, defaultOpen seeds it.
Swatch
ds-swatch — Clickable colour tile: a 24px <button> (20px compact) whose background is the value colour, passed through the --ds-swatch-color custom property so the stylesheet stays data-free. Shapes: circle (default, --radius-pill) and square (--radius-200). A hairline --color-bg-container-border inset keeps light colours visible on light surfaces; selected draws the theme-aware ring (--color-bg-page-primary gap + --color-text-primary outline) and sets aria-pressed. Purely presentational — no 'use client', so it renders from Server Components. Disabled: opacity: 0.4.
ColorPicker
ds-colorpicker — Colour picker composed in Field: a bordered trigger (--radius-300, input border tokens) holding a checkerboard-backed swatch and optional hex text (showText), opening a --shadow-floating panel with a saturation/brightness area (2D role="slider": pointer, arrow keys, and Home/End on the saturation axis), a hue slider, an optional alpha slider (showAlpha, emits 8-digit hex below 100%), and a hex field. Works controlled (value + onValueChange, fires live while dragging) or uncontrolled (defaultValue); name renders a hidden input for native form submission. The white/black overlay gradients and hue spectrum are colour-space constants written as hsl() literals — the one sanctioned departure from hex-free component CSS, since no theme token can represent them. Sizes: default, compact. Disabled: opacity: 0.4. The panel and its controls are named by panelLabel, saturationLabel, saturationValueText, hueLabel, alphaLabel and hexLabel. With no visible label, the trigger is named by triggerLabel(hex) ("Choose colour, #hex"); a passed aria-label still wins.
DateInput
ds-date-input — Form field wrapping the native date input with the standard input anatomy: label (--font-paragraph-emphasis-*), --radius-300 field on --color-input-bg-primary with --color-input-border-primary, hover/selected borders (--color-input-border-hover / --color-input-border-selected), calendar icon in --color-icon-primary, and helper/error text below (--font-paragraph-sm-*, error via --color-status-error-border). Supports min/max, required, disabled (input disabled tokens), and sizes default/compact. Value format is YYYY-MM-DD.
DatePicker
ds-date-picker — Inline calendar: month header with --radius-pill chevron nav buttons, a weekday header row (weekdayLabels, default Su–Sa in English and the locale's short names otherwise, with weekdayNames spoken in their place), starting on the locale's first day of the week (weekStartsOn overrides it), and a grid of day cells. The selected day fills --color-action-primary-bg with --color-action-primary-text at --radius-pill; today is highlighted; out-of-range days (min/max) and the disabled state get the standard muted treatment. Container: --radius-300 on --color-input-bg-primary with --color-input-border-primary. Sizes: default, compact. Emits onDateSelect with YYYY-MM-DD. Selection is controlled by design: the picker never sets value itself, so the host stores the date onDateSelect reports and passes it back as value; only the month in view and the focused day are the picker's own state. The month heading comes from monthLabel, the arrows from previousLabel/nextLabel, and each day's name from dayLabel; their defaults are written in the resolved locale. The days are a WAI-ARIA date grid (role="grid", rows of columnheaders and gridcells around the day buttons, laid out as subgrids of the one CSS grid) with one roving tab stop: the last-focused day, else the selected day, else today, else the month's first enabled day. Arrows move a day or a week, Home and End go to the ends of the week, Page Up and Page Down a month (with Shift, a year); a move past the month in view turns the page with focus on the new day, and a move past min/max is held at the bound, so focus never lands on a disabled day.
isDateDisabled(date) marks further days unavailable beyond min and max (weekends, holidays, booked days), given each as a YYYY-MM-DD string. The date helpers and the grid's keyboard move live in an internal calendar.ts beside it, and the month grid in MonthGrid, which DatePicker and DateRangePicker both compose with their own header and selection, so a month lays out and its keys move one way.
DateRangePicker
ds-date-range-picker — An inline calendar for a range of days, in the DatePicker family: its root, header, grid and day cells carry DatePicker's own classes and stylesheet (the root wears both base classes), and its own sheet adds only what a range needs. months shows one or two months side by side (two by default), wrapping one under the other when the picker is narrow; with two, each month shows only its own days. The first press sets start, the second sets end (a day before the start begins again), and onValueChange fires with each ({ start, end }, YYYY-MM-DD strings, end null between the two); value, defaultValue follow the pair convention. The two chosen ends take DatePicker's selected fill (--color-action-primary-bg, the selected-item role); the days between run as one band in --color-action-passive-bg-active with no gap between them, carried half into each end's cell. While the end is being chosen, the days from the start to the one under the pointer or the focus preview in --color-action-passive-bg-hover, the candidate end outlined in a dashed --color-action-primary-border. min, max and isDateDisabled hold days out; minLength and maxLength (in days, both ends counted) hold out, while the end is being chosen, any day that would make a range too short or too long, as does a span across an unavailable day. presets ({ label, range }) render as neutral Chips above the calendar, in a group named by presetsLabel, each pressed (aria-pressed) while its range is the chosen one. Keyboard as DatePicker, with one tab stop across every month shown. Each day's name says where it sits (rangeStartLabel, rangeEndLabel, inRangeLabel, or dayLabel for all of it), and each gridcell in the range carries aria-selected. name sends <name>[start] and <name>[end] through hidden inputs. No popover: a field that opens a range calendar is a later decision.
TimePicker
ds-timepicker — Time-of-day form field, the clock sibling of Dropdown. The trigger is a real <button> styled with the input anatomy (label via Field, --radius-300, input border/bg tokens, helper/error text), led by a schedule icon at --icon-size-500 and closed by a rotating chevron. Open state reveals a --radius-300 listbox on --color-bg-page-primary elevated with --shadow-floating, max-height 240px with scroll, which opens scrolled to the selected or first option. Options are generated from minTime/maxTime/stepMinutes (defaults 00:00, 23:30, 30) and rendered with tabular-nums; hourFormat (defaulting to the locale's clock) switches the display between "2:30 PM" and "14:30" while the value stays a 24-hour HH:MM string (controlled value or uncontrolled defaultValue, onValueChange). The selected option is marked with --color-action-primary-text-tertiary and the shared ChosenMark (see the Components intro); hover and keyboard focus use --color-action-passive-bg-hover; rows are --radius-200. Listbox keyboard pattern (arrows, Home/End, Enter, Escape) with aria-expanded, aria-activedescendant and aria-haspopup="listbox" wiring. Sizes: default, compact. The trigger carries role="combobox" (a select-only combobox, Dropdown's pattern), which is what makes its aria-activedescendant valid while the list is open. In the 12-hour format the period words come from periodLabels (am "AM", pm "PM").
EventCalendar
ds-event-calendar — Month-at-a-glance schedule: a full month grid with event pills on their days, an overflow row once a day is full, and prev/next month navigation. DatePicker selects a date; this one shows a schedule. The container is --color-bg-container-primary at --radius-300 with a hairline border and --padding-400; the header pairs the month name (--font-title-body-*) with two --radius-pill passive nav buttons and an optional actions slot (a "New event" Button). Day cells are --color-calendar-cell-bg at --radius-200 in a 7-column grid with grid-auto-rows: 1fr, so each week row shares the height of its fullest day instead of fixing a cell height; outside-month cells fade to the transparent container fill. Today's day number takes the inverse chip (--color-bg-page-inverse / --color-text-on-inverse) — the filled version of DatePicker's today ring, because a ring disappears at calendar density. The selectedDate day takes the action chip instead (--color-action-primary-bg / --color-action-primary-text, DatePicker's selected convention — the selected item of a mutually exclusive set), winning over the today chip when both land on one day. The month heading comes from monthLabel, the arrows from previousLabel/nextLabel, and the overflow row's name from overflowLabel. The month and weekday names and the spoken dates are written in the resolved locale, and the grid starts on its first day of the week (weekStartsOn overrides it).
Event pills are --color-calendar-event-bg chips at --radius-100: an accent dot, a truncating --font-paragraph-sm-emphasis-* title (.ds-event-calendar__event-title — a host squeezing the calendar into a miniature column may hide it, reducing pills to their dots; the titles survive as the pills' accessible names), and a tabular-nums time that hides under 768px so titles keep at least a few characters. The dot's colour routes through --ds-event-accent and resolves to the --color-core-accent-* roles (coral/violet/cobalt/amber/gold/mint; default neutral via --color-status-neutral-border) — never the action colour, which stays reserved for actions. Within a day, untimed events sort first, then by time text. Days past maxEventsPerDay (default 3) collapse into a "+N more" row. Pills, day numbers, and the overflow row render as buttons only when their callback (onEventClick, onDateClick) is provided — otherwise they are inert spans, so a read-only calendar contains no fake affordances. With onDateClick set, the whole cell is a pointer target too (cursor: pointer, hover to --color-calendar-cell-bg-hover), while the day-number button stays the accessible control; clicks landing on a button inside the cell defer to that button. Cell and pill fills come from the calendar-surfaces roles: the cell keeps its hover while the pointer is on a pill inside it, and the pill separates through its own hover (--color-calendar-event-bg-hover, the resting cell tint — one step off the hovered cell, darker than it in dark and lighter in light) plus a hover-only --color-calendar-event-border ring; resting pills carry a transparent border, so the ring costs no layout shift. The "+N more" overflow row hovers onto the same --color-calendar-event-bg-hover step. Months are YYYY-MM strings on the controlled/uncontrolled pair convention (month/defaultMonth/onMonthChange); unlike DatePicker's day grid, it is deliberately not role="grid": it has no 2D keyboard model, so a labelled group of buttons is the honest structure. The weekday row comes from weekdayLabels and the visible "+N more" from moreLabel.
Dropdown
ds-dropdown — Select-style form field. Closed state matches Input anatomy (label, --radius-300, input border/bg tokens, helper/error text); open state reveals a --radius-300 listbox on --color-bg-page-primary elevated with --shadow-floating, max-height 240px with scroll, placed by the shared anchored-position behaviour (flipped above when there is no room below, capped to the room it has). The list sizes to its longest option, never narrower than the trigger and capped inside a phone's width, and an option never wraps. Open state follows the pair convention (open/defaultOpen/onOpenChange). Options support disabled entries, grouped sections (groups) with headings and separators, an optional per-option icon — a Material Symbol at --icon-size-500 in --color-icon-primary, drawn before the label in the option row and on the closed trigger while selected (inline in the value so its single-line ellipsis holds) — and an optional per-option font — a CSS stack the option's label renders in (routed through the --ds-dropdown-font custom property, runtime data rather than theme), so a typeface picker previews each face in the row and in the closed trigger. The selected option is marked with --color-action-primary-text-tertiary and the shared ChosenMark (see the Components intro); hover uses --color-action-passive-bg-hover; rows are --radius-200. Listbox keyboard pattern (arrows, Home/End, Escape, Enter) with aria-expanded/aria-activedescendant wiring. Sizes: default, compact. Width is deliberately 100% — a form field fills its field column; when a Dropdown is toolbar furniture instead (a date-range select in a panel header, a table filter), the consumer caps it with a fixed width via className, which is the sanctioned sizing hook. For action menus (not form values) use DropdownMenu. The selection follows the controlled/uncontrolled pair convention (value/defaultValue/onValueChange). A consumer's aria-label, aria-labelledby and aria-describedby reach the combobox trigger, and a passed description is joined to the helper's. name submits the selected option's value through a hidden input (nothing while no value is set, nothing while disabled), the pattern Combobox set, and is also the id fallback.
Combobox
ds-combobox — Filterable select: a text field that narrows a listbox as the user types. Control matches Input anatomy (label, --radius-300, input border/bg tokens, helper/error text) with a leading 24px search icon and a trailing chevron that rotates when open; the menu is a --radius-300 surface on --color-bg-page-primary elevated with --shadow-floating, max-height 280px with scroll. Options carry an optional description second line (--font-paragraph-sm-* tertiary) and support disabled entries plus grouped sections. multiple renders selections as --radius-pill chips in --color-bg-container-primary inside the control, each with a remove button; Backspace on an empty query pops the last chip. clearable adds a clear button, loading swaps the list for a status row, and emptyMessage covers the no-match case. Async callers pair onSearchChange with manualFiltering to filter upstream. Full combobox keyboard pattern (arrows, Enter, Escape, Tab) with aria-expanded/aria-activedescendant wiring. Sizes: default, compact. Use Dropdown when the option list is short and static; Combobox is for long, searchable, or server-backed lists. The selection follows the controlled/uncontrolled pair convention (value/defaultValue/onValueChange), a string in single mode and an array when multiple; the chip remove buttons and the clear button are named by removeLabel ("Remove <label>") and clearLabel ("Clear selection"). The loading status text is loadingMessage. name submits the selection, never the search text, through hidden inputs (one per selected value in multiple mode, none while disabled). A consumer's aria-label, aria-labelledby and aria-describedby reach the text input, and a passed description is joined to the helper's rather than replacing it.
RichDropdown
ds-rich-dropdown — Dropdown's rich sibling: a select where every option is a self-portrait. The shared cell pairs a 24px swatch dot of the option's key colour (Swatch's geometry — hairline --color-bg-container-border inset so light colours stay visible; --radius-pill by default, with a per-option swatchRadius override so a sharp-cornered look squares its own dot) with the option name set in its own heading face and a detail line set in its own body face. The per-option fonts, colour and swatch radius are runtime data, not theme, so they reach the stylesheet through custom properties (--ds-rich-dropdown-heading-font, --ds-rich-dropdown-body-font, --ds-rich-dropdown-color, --ds-rich-dropdown-swatch-radius), falling back to --font-family-heading / --font-family-body when an option carries no faces. The name takes --font-title-body-*, the detail --font-paragraph-sm-* in --color-text-tertiary. The closed trigger renders the selected option with the same cell, so the current choice previews itself too, which is why the trigger takes --control-height-default as a minimum and grows to hold both lines. Everything else is Dropdown's anatomy: Field composition, --radius-300 trigger on the input tokens, a --shadow-floating listbox on --color-bg-page-primary (max-height 320px — the cells run taller than Dropdown's plain rows), selected name in --color-action-primary-text-tertiary with the shared ChosenMark, hover/focus rows on --color-action-passive-bg-hover, and the full listbox keyboard pattern with aria-expanded/aria-activedescendant wiring. Controlled (value + onValueChange) or uncontrolled (defaultValue). The dividing line with Dropdown: when the label alone is enough, use Dropdown — its per-option font already covers a typeface previewing itself; RichDropdown is for choices whose whole look is the point — theme presets, brand kits, style packages. A consumer's aria-label, aria-labelledby and aria-describedby reach the combobox trigger, and a passed description is joined to the helper's. name submits the selected option's value through a hidden input, as Dropdown does.
FileInput
ds-file-input — Click-or-drop upload zone plus a list of chosen files. The dropzone is a dashed --border-025 --color-input-border-primary rectangle at --radius-300 with a centred 24px upload_file icon and instruction copy; hovering moves the border to --color-input-border-hover, and an active drag fills --color-bg-container-primary with the selected border. The real <input type="file"> stays in the DOM (visually hidden) for form semantics, with the zone exposed as role="button" and Enter/Space activation. The file list is fully controlled — each row is a --radius-300 hairline container with a description icon, name, human-readable size, an optional 4px progress track filled in --color-core-accent-mint (ProgressBar's fill), and a 32px ghost remove button. Per-file error swaps the row to the error tokens with an error icon and message in --color-status-error-text. Sizes: default, compact. Each file's progress and remove controls are named by progressLabel and removeLabel. The accept hint is acceptHint(accept).
CurrencyInput
ds-currency-input — An amount of money, built on Input with the currency as its prefix or suffix: the side and the form (currencyDisplay: symbol, code or none) come from Intl in the resolved locale, never from a guess at left or right ($ before in en-US, € after in de-DE). The value is a decimal string through rift-ds/format (design.md's "Money is a decimal string"): value, defaultValue and onValueChange(string | null) follow the pair convention, the native onChange fires too, and name sends the decimal string through a hidden input, never the grouped text. While focused the field shows the raw number in the locale's decimal mark; on blur it groups the digits at fractionDigits (the currency's own by default) and holds the amount to min, max and allowNegative. A paste with a symbol, a code or another convention's marks ($1,234.5 into a German field) is read for its number. Up and Down step by step (a decimal string, "1" by default). No spinner buttons, a deliberate difference from NumberInput: an amount is typed, not nudged. inputMode="decimal", tabular figures, both sizes, and every Input state.
MaskedInput
ds-masked-input — A field that writes its value into a shape as it is typed, built on Input. mask is a pattern (# a digit, A a letter, * either, every other character written as it stands) or a function from the raw characters to the shown text; MASK_PRESETS holds a US-shaped phone number, a card number, a nine-digit ID number, a postal code and a licence key, as data rather than a claim about any locale. Separators arrive only as far as the last filled slot. The value is the raw characters (value, defaultValue, onValueChange(raw, formatted)), and name sends them through a hidden input. An edit is read back by comparing the shown text before and after, so the caret holds its place across separators, a paste in another shape is cleaned up, and Backspace just after a separator removes the character before it. sensitive shows the value as dots until revealed, with autoComplete="off" by default; visibleTail keeps the last characters readable while the field is not focused; revealable adds a compact tertiary CircularButton in Input's action slot (visibility and visibility_off, aria-pressed, named by showLabel and hideLabel), and revealed/defaultRevealed/onRevealedChange follow the pair convention. autoHideAfter hides a revealed value again after a time (true for MOTION_SENSITIVE_HIDE_MS). Tabular figures, and the numeric keyboard for an all-digit pattern.
SignaturePad
ds-signature-pad — A signature, drawn or typed, composed inside Field's group mode, so it takes a label, helper text and errorText like any control. mode="both" (the default) puts a SegmentedControl over a draw panel and a type panel; typing is the way in for anyone who cannot draw with a pointer, so neither method is the only one unless a host chooses draw or type. The surface is the input family's (--color-input-bg-primary behind a --color-input-border-primary hairline at --radius-300, the hover border, the error border, the disabled treatment), four control rows tall (height="tall", six), with a dashed --color-divider baseline seven tenths down and placeholderLabel ("Sign here") on it in the caption face until the first stroke. Drawing uses pointer events with pointer capture and the coalesced points; each stroke is smoothed through the midpoints of its points and written straight to its SVG path every frame, and React state commits once when the pointer lets go (the drag rule; the pad is in scripts/gestures.json). A pen or finger that reports pressure thickens or thins the stroke around its base width. Strokes draw in currentColor, the primary text colour. Undo takes back the last stroke and Clear empties the pad (compact tertiary Buttons, undoLabel, clearLabel); addedLabel and clearedLabel are announced politely. The typed name sets in the body face, italic, at the heading-2 size on the same baseline: no signature face ships, so a host that wants one sets font-family on ds-signature-pad__preview. onValueChange hands over { kind, svg, text?, png }: the signature as a standalone SVG document in currentColor, so it scales and prints, and a PNG made on demand at twice its size. name sends the SVG through a hidden input. A drawn value cannot be loaded back for editing; a controlled null clears the pad.
NumberInput
ds-numberinput — Numeric field composed in Field, with the native spinners hidden and replaced by decrement/increment stepper buttons (Material Symbols remove / add at --icon-size-500) flanking a centred <input type="number">. The shell carries the input anatomy: --radius-300 (12px), --color-input-bg-primary fill, --color-input-border-primary hairline, hover --color-input-border-hover, :focus-within --color-input-border-selected. Steppers clamp to min/max and disable at the bounds; a typed out-of-range value clamps into range on blur; stepping from an empty field starts at 0 and clamps into range. Works controlled (value, number | '') or uncontrolled (defaultValue); onValueChange receives number | null (null when empty) and fires on typing alongside the native onChange, and alone on stepper clicks, where no native change event exists. Stepper hover uses --color-action-passive-bg-hover. Sizes: default, compact. Error: --color-status-error-border on the shell. Disabled: the input disabled token set on the shell, cursor: not-allowed, steppers at opacity: 0.4. The steppers are named by decreaseLabel/increaseLabel.
TagInput
ds-taginput — Multi-value text input holding committed entries as removable tags. The shell (ds-taginput__shell) is a focus-within box matching Input anatomy: --radius-300, --color-input-bg-primary fill, --color-input-border-primary hairline, hover --color-input-border-hover, focus-within --color-input-border-selected, error --color-status-error-border on border and helper text. Tags are --radius-pill pills in --color-bg-container-secondary with --font-paragraph-sm-emphasis-* labels and a trailing remove button (Material Symbols close at --icon-size-500, accessible name "Remove {tag}"). Enter or comma commits the trimmed draft as a tag (no duplicates, no empty entries); Backspace on an empty draft removes the last tag; maxTags stops new entries at the limit. Controlled via values plus onValuesChange, uncontrolled via defaultValues; the native onChange fires for draft edits. Composes Field for the label, helper and error wiring. Sizes: default, compact (draft text drops to --font-paragraph-sm-*). Disabled: opacity: 0.4, cursor: not-allowed, tags not removable. Use Combobox when the values come from a known option list; TagInput is for free-text entries. Each remove button is named by removeLabel(tag). name submits the committed tags through hidden inputs, one per tag; the draft text is never submitted.
PinInput
ds-pininput — Segmented one-time-code input composed in Field: a row of one-character cells (--gap-200 between them) below the standard label, with helper/error text underneath. Each cell is a 44×52px <input maxLength={1}> at --radius-300 on --color-input-bg-primary with a --color-input-border-primary hairline, centred --font-paragraph-emphasis-* text, and the input hover/focus borders (--color-input-border-hover / --color-input-border-selected); keyboard focus adds the 2px --color-action-primary-bg ring. Typing fills a cell and advances focus, Backspace clears and retreats, arrows move, and paste distributes a full code from cell 0. format is numeric (default, rejects non-digits, inputMode="numeric") or alphanumeric; mask renders password cells; length defaults to 6. onValueChange emits the joined code, onComplete fires once when every cell fills; the first cell carries autoComplete="one-time-code" for OTP autofill. The cells wrap in a role="group" labelled by Field's label, and every cell has an accessible name ("Digit 1 of 6"). Error: --color-status-error-border on cell borders. Disabled: cells at opacity: 0.4, cursor: not-allowed, input disabled tokens. Each cell is named by cellLabel(position, count). name submits the joined code through a hidden input; the cells carry none.
FilterBar
ds-filter-bar — A wrapping row of filter chips at --gap-200 for narrowing a collection. Each chip borrows Chip's pill language (--radius-pill, --color-bg-page-primary on a --border-025 --color-bg-container-border hairline, --font-paragraph-sm-emphasis-* in --color-text-secondary); a filter with anything active flips to the action selected fill (--color-action-primary-bg / --color-action-primary-text, matching Chip's selected convention) and grows a trailing clear button. The chip summarises its state: bare label, "Label: Option" for one choice, "Label: n" for more. The popover is a real listbox — --color-bg-container-primary at --radius-300 under --shadow-floating, options at --font-paragraph-sm-*, a multi-select row led by the checkbox indicator (CheckboxIndicator, compact) and a single-select row marked by the shared ChosenMark (see the Components intro) — driven by aria-activedescendant so focus never leaves the chip: arrows move, Enter/Space toggle, Escape closes, single-select filters (multiple: false) close on choice. State is one Record<filterId, string[]>, controlled (values + onValuesChange) or uncontrolled (defaultValues); a "Clear all" text button appears once anything is active. compact tightens to Chip's compact paddings. Each filter's clear control is named by clearFilterLabel.
Rating
ds-rating — Star-scale rating control. A radio group of Material Symbol steps (icon defaults to star, max to 5): filled steps take --color-core-accent-gold with the icon font's FILL axis at 1, empty steps sit in --color-icon-secondary. Hover previews the would-be selection (steps scale to 1.1 at --motion-duration-fast); selection is controlled (value) or uncontrolled (defaultValue) with onValueChange, and allowClear lets re-selecting the current step clear back to 0. Keyboard follows the radio-group pattern: the checked step holds the roving tab stop, arrows move and select, Home/End jump to the ends. readOnly renders the same row as role="img" with a spoken "N out of M" label; disabled dims to the standard 0.4. Sizes: default (--icon-size-600 steps), compact (--icon-size-500). The spoken value is valueLabel(value, max), "N out of M" by default, read after label and a colon.
Toast
ds-toast — Ephemeral notification via ToastProvider + useToast() hook. Rendered via React portal into document.body. Default duration: 5000ms. Pauses on hover/focus. Progress bar animates across bottom. Position: configurable (top-right, bottom-right, etc.), defaults to bottom-right. Max stack: 5 toasts. Five variants share the same --color-status-* token mapping as Badge/Alert. The container sits on --layer-toast, above every modal (Elevation & Depth → Stacking owns the ladder), flush to its corner; the layer and the corner are both consumer hooks. A host that keeps its own fixed chrome in that corner sets --ds-toast-inset-top/bottom/left/right so the queue stacks clear of it, and raises --ds-toast-z above its own overlays: toasts are transient feedback and must be seen, so they outrank persistent chrome. The website does exactly this for its chat launcher (globals.css), and moves the queue left of the panel while it is docked on the right; on the left dock only the bottom inset changes, since nothing stands in the queue's way. The standalone Toast renders its dismiss button only when given onDismiss; the region and dismiss names are regionLabel and dismissLabel on ToastProvider.
The standalone Toast and ToastProvider's toasts take an action, a control under the text, such as an Undo; one whose control must not leave on a timer sets duration to 0. useOptionalToast returns null without a provider, so a component can join the host's stack or bring its own.
Default icons (Material Symbols Rounded): info, check_circle, warning, error, info for each variant. Override via icon prop. Dismissal plays the exit animation (--motion-duration-base), the unmount following on MOTION_EXIT_SYNC_MS.
NotificationCenter
ds-notification-center — The persistent inbox for everything that happened while the user was away: Toast interrupts, this accumulates. A --color-bg-container-primary panel at --radius-300 with a hairline border, so it works dropped from a bell icon or inline on a page. Header: --font-title-body-* title with a tertiary "N unread" line beside it (a polite live region), and a mark-all-read text control on the right in --color-action-primary-text-tertiary (an action, so it may take the action colour) that only renders when onMarkAllRead is provided. Optional filter tabs sit under the header — --radius-pill pills with --font-paragraph-sm-emphasis-* labels and tabular-nums counts, active pill on --color-bg-container-tertiary, role="tablist" with arrow-key movement, each tab wired to the list below via aria-controls — and the list, a role="tabpanel" labelled by the active tab, scrolls beneath a --color-divider rule. Filtering is the consumer's job: swap the children when the tab changes. No children renders a built-in compact EmptyState (overridable via emptyState). tabsLabel names the filter tablist and unreadCountLabel writes the announced unread line; NotificationItem's visually hidden "Unread" is unreadLabel. The active filter tab follows the value pair, value/defaultValue/onValueChange, like Tabs and SegmentedControl; activeTab, defaultTab and onTabChange remain as deprecated aliases, and the new names win when both are set. The built-in empty state's copy is emptyStateLabels, merged over "Nothing new" and "Notifications will collect here as they arrive."
ds-notification-item — One row of the inbox, exported alongside: a leading media slot (Material Symbol name or an element such as an Avatar, --icon-size-600) rendered as a fixed column the width of its largest medium — an sm Avatar, the --icon-size-800 rung — with smaller media centred in it, so every row's text shares one left edge whether its medium is an avatar or a bare icon; a title row with right-aligned time (--font-paragraph-sm-* tertiary), supporting copy in --color-text-secondary, and an actions row for compact Buttons. unread emphasises the title to --font-paragraph-sm-emphasis-* and adds an 8px dot filled with --color-status-info-border at full border weight, Meter's small-mark reasoning — the weight change alone is too quiet to scan for, so the dot is the signal and has to register at a glance — informational, so it takes the info status colour rather than the action colour — with visually hidden "Unread" text for screen readers. Rows separate with --color-divider hairlines; renders a semantic <article>.
ProgressBar
ds-progress-bar — Determinate progress as a horizontal --radius-pill track in --color-bg-container-tertiary with a --color-core-accent-mint fill scaled to the clamped 0–100 value. Optional percentage label (showLabel) in --font-paragraph-sm-emphasis-*. role="progressbar" with aria-valuenow/-valuemin/-valuemax and an ariaLabel describing what is loading. Sizes: default, compact (bar height).
Meter
ds-meter — Level indicator for a known quantity: storage used, tokens spent, password strength, battery left. The counterpart to ProgressBar — same --radius-pill track in --color-bg-container-tertiary, but the number means "how full", not "how far along", and the fill is coloured by the five status roles so the level carries its own judgement. The fill uses the full-strength --color-status-*-border weight, not the tinted -bg — a tint would vanish in an 8px ribbon. Optional header row: label in --font-paragraph-sm-emphasis-* on the left, a showValue readout in tabular figures on the right (valueText replaces the default percentage and is spoken via aria-valuetext — "128k of 200k tokens"). Custom min/max ranges; the fill animates between levels at --motion-duration-base and grows in from empty on mount (--motion-duration-slower --motion-ease-emphasized, collapsed by the reduced-motion token guard). role="meter" with the value attributes, named by label or aria-label. Sizes: default (8px track), compact (4px). Presentational — no 'use client', renders from a Server Component.
Skeleton
ds-skeleton — Loading placeholder with a pulsing fill stepping between --color-bg-container-primary and --color-bg-container-secondary. Three shapes: text (--radius-200 line, multi-line via lines — the last line renders at 75% width), circular (--radius-pill), and rectangular. width/height accept any CSS length. Announced as role="status" with aria-label="Loading" and aria-busy.
Spinner
ds-spinner — Indeterminate loading indicator: an SVG circle with a --color-bg-container-tertiary track and a rotating arc in --color-text-primary (tone="default") or --color-text-secondary (tone="neutral"); tone="inherit" draws both circles in currentColor (track at 0.25 opacity) so the spinner matches the surrounding control — it's what Button and CircularButton use for their loading state. Sizes sm/md/lg (16/24/36px). The single-axis variant (primary/neutral/inherit) is the deprecated spelling of tone. role="status" with a configurable label. Use Spinner for indeterminate waits and ProgressBar when the completion fraction is known.
StatusDot
ds-status-dot — The bare status mark: a --radius-pill dot in one of the five status roles beside an optional --font-paragraph-sm-* label in --color-text-secondary. The dot colours through the plain-surface --color-status-*-icon steps (the deep inks in light, the mid-ramp hues in dark), never the text tints — it sits on bare containers, where the tinted-text steps wash out. Sizes ride the icon scale at half-diameter (sm/md/lg = half of --icon-size-500/600/800 — 10/12/16px), so the dot can never drift from the glyphs it sits beside; xs is the one step off it, the 8px list-row mark on --gap-200 (the gap scale because no icon token is that small), which ThreadPanel and ThreadTabs compose. outline draws the dot hollow — the role's colour as an inset ring (--border-025 at xs/sm, --border-050 at md/lg) around an empty centre — for a settled or empty state that still holds its seat beside filled dots. pulse radiates a ring of the dot's own colour (a ::after in the role's colour, swelling to 2.2× and fading on --motion-duration-loop-shimmer) for live states — recording, online now, deploy in flight; under reduced motion the ring parks invisible rather than strobing. Where Badge is a text label on a tinted fill, StatusDot is the mark alone — table rows, avatars, nav items. Root is role="status"; the dot itself is aria-hidden, so a bare dot needs its meaning carried by a row label or a passed-through aria-label; decorative drops the role and hides the whole mark, for a dot repeated down a list whose host announces the state in the row's own text. Together the three give a list one reading — fill says whether a row wants the reader, colour says what it wants, motion says it is live — which is the thread status convention ThreadPanel's spec owns. Purely presentational — no 'use client'.
EmptyState
ds-empty-state — Placeholder for a list, table, search, or dashboard with nothing to show. Centred stack: a 48px --radius-pill --color-bg-container-primary disc holding a 24px Material Symbol, a --font-title-body-* headline in --color-text-primary, supporting copy in --font-paragraph-sm-* tertiary capped at 420px, and an action slot that wraps consumer-provided Buttons. variant="bordered" adds a dashed --color-bg-container-border container at --radius-300 — use it inside a card, table, or panel; plain sits directly on the page. size="compact" drops to a 36px icon disc, --font-paragraph-emphasis-* headline, and --padding-500, for dense sidebars. The icon prop takes a Material Symbol name or an arbitrary element for illustrations. Write the description as the next action, not just a statement of absence.
Card
ds-card — Table-of-contents navigation tile. Two parts: .ds-card__preview (content/component preview area) + .ds-card__title (h3 label below). Used in component/foundations index grids. When interactive, gains role="button" and keyboard support. Background: --color-bg-container-primary-semi; radius: --radius-600 (24px). Interactive and case-study variants lift on hover with 0 8px 24px rgba(0,0,0,0.3) — the system's one sanctioned container shadow (see Do's and Don'ts). The case-study variant's cover slot takes either an image (coverSrc) or drawn content (cover — an SVG, a chart, a live preview); both fill the same fixed-ratio box, so a grid keeps one cover shape whichever kind each card was given. The cover is inset, not full-bleed: --padding-300 (12px) of card surface surrounds a --radius-300 (12px) cover, keeping the inner corners concentric with the card's 24px shell — the same geometry rule the Composer follows.
EntityCard
ds-entity-card — Compact icon-or-image + label card. Used specifically on Icons and Logos pages in the Foundations section. Takes a Material Symbol name (icon) or image path (imageSrc). Centered layout with label beneath. Radius: --radius-600 (24px), matching Card. Not interactive — display-only.
SelectionCard
ds-selection-card — Card-based form control for selecting from a list. Three modes: radio (single select), checkbox (multi-select), toggle (per-card on/off). Each card renders the option label, optional description, and the real control's own indicator at its end (RadioIndicator, CheckboxIndicator or SwitchIndicator, so the mark is exactly the one the standalone control draws; the card carries ds-control-host, so hovering it lights the indicator). The card is a form control and wears Input's surface: --color-input-bg-primary behind a --border-025 --color-input-border-primary hairline at --radius-300, --color-input-border-hover on hover. Full keyboard navigation and ARIA roles. Selected state: the --color-input-border-selected border, the way an active field is marked; the fill stays, so a picked card does not change weight. Use when options need more space or explanation than a standard radio/checkbox group. The selection follows the controlled/uncontrolled pair convention (value/defaultValue/onValueChange), a string in radio mode and an array otherwise. name is the form key and the group's fallback accessible name, so a form gives the group its name with aria-label.
When to use which card:
Card→ navigational tile in a documentation index gridEntityCard→ icon or logo display in a foundations catalogSelectionCard→ form input when options need card-style layout
CardStack
ds-card-stack — A deck of cards showing one at a time, the rest peeking out beneath it; the stack is the pattern, not the cards — any children work, Card and EntityCard are the intended fillings, and every card in a deck should share one size. Rendering is a CSS grid with every item in the same cell; each item's place in the deck is a single transform driven by two custom properties — --ds-card-stack-peek (the per-card offset, default --gap-400) times the item's --ds-card-stack-pos, with scale(1 - 0.04 × pos) from a bottom-centre origin so buried cards narrow while their bottom edges still peek below. The container pads its own bottom by peek × --ds-card-stack-peek so the deck occupies its true height. Flipping forward plays a lift-and-settle: the top card animates up and away (translateY(-18%), a 3° tilt, fading — percentages of the card itself, so the choreography scales with whatever the deck holds) over --motion-duration-deliberate --motion-ease-emphasized, and the index commits on animationend, never a timer, so reduced motion (which collapses the duration via the token guard) commits immediately. Flipping back commits first and drops the incoming card in from above on --motion-ease-entrance. Position moves between slots transition on the same duration. Flip by clicking the top card (clicks landing on a link or control inside the card keep their meaning — advanceOnClick turns the rest off), or with the arrow keys when the stack has focus. index/defaultIndex/onIndexChange follow the controlled/uncontrolled pair convention; loop (default on) wraps the deck. The root is a focusable role="group" with aria-roledescription="card stack"; non-top cards are inert and aria-hidden so a buried card's controls can never take focus, and a visually hidden role="status" announces "Card X of N" as the deck turns. The position announcement is positionLabel(position, count).
Widget
ds-widget — A titled tile for a dashboard or a home screen, with the three pieces that fill and arrange it. The tile is a <section> named by its title (overridable with aria-label): the heading in --font-title-body-* primary (an h3 by default, titleAs moves it in the outline) with an optional count trailing it in --font-paragraph-sm-* tertiary, an optional action slot opposite, then the body. The surface is the translucent container (--color-bg-container-primary-semi) over a 24px backdrop blur behind a --border-025 hairline in --color-bg-container-border, at --radius-600 with --padding-500 — the glass recipe's quiet form, so a board of tiles sits over an ambient background without going flat, and reads as a bordered card on a plain page. No shadow: depth stays token-owned. ds-widget-row (WidgetRow) is one line of a tile: a leading mark (a StatusDot, PixelAvatar, Avatar, Spinner or icon, sized on --icon-size-600), a title in --font-paragraph-sm-emphasis-* over an optional description in --font-caption-* tertiary (both ellipsised), and a trailing meta in the caption face with tabular numerals. It renders as an <a> with href, a <button> with onClick, and a plain <div> otherwise, so a list offers a target only where there is somewhere to go; an interactive row's hover fill (--color-action-passive-bg-hover, --radius-300) bleeds into the tile's padding so the whole line reads as one target. ds-widget-group (WidgetGroup) splits rows into labelled runs (role="group", the label in the caption face). ds-widget-grid (WidgetGrid) is the board: CSS columns, so tiles of different heights pack with no holes beside the tall ones, capped at columns (1 to 3, default 2) and folding to fewer as the board narrows — the column floor is stated on the icon scale rather than as a breakpoint. All four are presentational and server-renderable. Where Panel is the bare surface a dashboard composes on, Widget is the finished tile: reach for Panel when the content brings its own header.
A tile also serves a form section, a settings group or a review summary. description adds a line under the title in --font-paragraph-sm-* secondary, which also describes the section (aria-describedby). footer adds a slot under the body behind a --border-025 --color-divider rule, --padding-300 above it, its contents set to the end (Save and Cancel, a total, a link to everything). padding="compact" tightens the inset to --padding-300, and "none" lets the body run edge to edge (a table, a map) while the header and footer keep the tile's inset. collapsible makes the title a disclosure button (aria-expanded, an expand_more chevron turned a quarter towards the reading direction while closed) that shows and hides the body and footer, which leave the tab order and the accessibility tree while hidden; open, defaultOpen (true) and onOpenChange follow the pair convention. The button is a small client island (WidgetDisclosure), so the tile stays server-renderable.
DescriptionList
ds-description-list — Terms and their values, the chat cards' facts recipe made general: terms in --color-text-secondary, values in --color-text-primary, in the paragraph face (size="compact" the small one). Entries come as items or as DescriptionListItem children, each a <div> holding its <dt> and <dd>. layout="stacked" sets the term above the value; inline sets them in two columns, the term a quarter, a third or a half of the row (termWidth), and stacks them when the list's own frame is 360px or narrower; grid lays stacked entries out in columns (1 to 4), dropping to two at 720px and one at 480px. The frame is the size container, so all of it follows the room the list has, never the viewport. dividers draws --border-025 --color-divider rules between entries with --padding-300 around each, and align="end" sets values against the far edge for amounts. An empty value shows emptyValue ("Not set") in --color-text-tertiary, never a dash. copyable adds a compact tertiary CircularButton that copies the value (or the string given) and reads "Copied" for a moment after (copyLabel, a function of the term, and copiedLabel), reporting the copy through onValueCopy; action takes a trailing control such as an Edit button. The copy button is a client island beside a server-renderable list. The chat cards keep their own facts rule, since the card recipe owns those values.
Panel
ds-panel — The plain dashboard surface: --color-bg-container-primary fill at --radius-600, --padding-500 inside, a --gap-400 column, no border and no shadow. Card cannot play this role — it requires a title and carries its own navigational look — so Panel is what a dashboard's regions build on, with charts dropped in bare and LegendTile rows beneath them. padding="compact" steps to --padding-400, "none" removes it for full-bleed content. Presentational and server-renderable.
BentoTile
ds-bento-tile — A feature tile for a bento grid: a heading over a content area, on one of three backgrounds, that can open into a full overlay. The tile is an <article> at --radius-400 behind a --border-025 hairline in --color-bg-container-border; the heading (headingLevel, an h3 by default, never an h1, since a tile is never the page's top heading) sets in the --font-heading-2-* face at the sub-display weight, balanced and capped at 22ch (a deliberate exception to one weight per tier: a feature tile's title is display copy at a heading's size, so it takes the display tier's weight), with an optional description in --font-paragraph-* secondary under it.
The content area is free and the guidelines hold it. children can be anything; the tile owns only where it sits. Content lays out in flow under the heading, inside a safe inset (--ds-bento-tile-inset, --padding-500 by default), so it can never land under the heading or the expand control. align (start, center the default, end) places it in the space, using auto margins so a box taller than the space starts at the top and overflows the foot rather than the head. Whatever does not fit is clipped at the content area and fades out over --ds-bento-tile-fade (--gap-1000) at the edge it overflows, measured live, so a hard cut never shows. The content area is a size container named ds-bento-tile: content answers the tile's width with @container ds-bento-tile, not the viewport's, because a tile in a three-column grid is as narrow as a phone. The rule for content is that every component inside keeps its natural width: a scene may fade at the tile's edge, but nothing inside it truncates, so content drops a part (a side stat, a column) at narrow widths rather than squeezing it. A surface placed inside a tile steps along the container ramp from the tile's own: on a solid tile a Panel is the tile's surface again and disappears, so a nested card wants --color-bg-container-secondary or no fill at all. A tile fills its grid cell (height: 100%); a grid whose rows are minmax(row, auto) lets a row grow to fit its tallest tile, and a fixed row shows the fade.
Backgrounds. shader (the default) is the container surface with a shader pair over it; solid is the surface alone; transparent is no surface, the border and the content. A shader pair is two halves that always come together: a wash, a ShaderField of three soft light sources pooled in the bottom-left corner under a radial mask, and a texture, a BentoTileArt pattern laid over it in the same corner, so light and pattern read as one surface. Six pairs exist and pair N is ambient colour N with texture N: the wash is led by --color-ambient-N with its two neighbours under it, and the texture is drawn in the same colour. Tiles rotate through the six by position among their siblings, so a grid of shader tiles comes out told apart with no configuration, and highlight (1 to 6) pins a pair; a pin outranks the rotation. The pair samples the ambient tokens rather than the core accents because a theme can tune the ambient slots toward its key colour without moving the chart palette, so the pair reads as the theme. Both halves share one strength, --ds-bento-tile-shader-opacity (half by default), because at full strength the pair competes with the content over it. Before the field resolves, and wherever WebGL is missing, a gradient in the same three colours stands in under it.
Expanding. Passing expandedContent makes the tile a control: the whole tile opens a modal overlay, and a compact CircularButton in the top-right corner (open_in_full) is its accessible handle, with aria-haspopup="dialog" and aria-expanded. The content area goes inert while the tile is expandable, since a control inside a control cannot be reached. Hovered or with its handle focused, the tile's border takes --color-action-primary-bg-hover, doubled to two hairlines by an inside outline so nothing moves, and the handle steps up to the primary variant: the tile is an action, so it signals with the action colour and nothing decorative. The overlay is a role="dialog" panel named by the tile's title, in the container surface at --radius-400 with --shadow-modal, over a frosted backdrop of --color-bg-page-primary at 55% with a 14px blur; it sits on --layer-modal, is capped at --ds-bento-tile-panel-width, and below 600px fills the viewport. It joins the shared behavior layer: the dismissal stack (Escape), the focus scope (focus to the close control, page inert, focus restored to the handle), and the counted scroll lock in its cancel-events strategy, which keeps the page scrollbar rendered so the page cannot shift sideways under the morph. Opening is a shared-element morph through the View Transitions API where the browser has it and the visitor allows motion: tile and panel take one transition name in turn, the root never crossfades (its rules are scoped by a class on the root for the length of the tile's own morph), and the backdrop stays outside the transition because a snapshot cannot sample a blur. The overlay's body rises in after the box has travelled. Controlled with open and onOpenChange, or uncontrolled with defaultOpen. The handle is named by expandLabel ("Open" and the title by default, or "Open" alone when the title is not a string), and the overlay's close control by closeLabel ("Close").
BentoTileArt
ds-bento-tile-art — The texture half of a BentoTile's shader pair, exported for a surface that wants it outside a tile. Six patterns in one vocabulary: dots, lines, rings, grid, waves and rays, in the order tiles rotate through them. Every pattern is drawn in 1px marks of its tile's highlight colour at 45% before the pair's shared opacity (on its own, outside a tile, there is no pair and no wash under it, so the marks take --color-text-tertiary, which reads on either theme's surface), eases in and out around the bottom-left corner (nothing at the corner itself, where rings and rays converge, swelling in and fading toward the middle, with the bottom and left edges eased so no mark meets the border), and drifts slowly along its own repeat; rings and rays hold still. Only the geometry differs. Rays are stroked lines with a non-scaling stroke rather than a conic gradient, whose marks widen with distance from the corner. Pattern sizes are drawing geometry in px; the drift is decorative and stands still under reduced motion. Inside a tile the pattern follows the tile's pair; pattern pins one directly. Pure CSS, presentational and server-renderable, hidden from assistive technology.
SplitPane
ds-split-pane — Two resizable regions with a draggable divider: sidebar and canvas, list and detail, editor and preview. The split is a percentage of the first pane riding a --ds-split-pane-split custom property, so it survives container resizes; panes clip their content (overflow: hidden, min-width/height: 0) rather than growing the page, and a region that should scroll brings its own focusable scroll container (a bare scrollable pane would fail axe's scrollable-region-focusable rule, and a pane that is always a tab stop would be worse). The divider is a --gap-200 hit area (transparent, tinting to --color-bg-container-secondary on hover and drag) holding a --radius-pill grip bar in --color-bg-container-border that darkens to --color-icon-secondary when engaged; both transition at --motion-duration-fast. It is a real role="separator": focusable, aria-valuenow reporting the percentage, arrow keys nudging by 2 (Shift for 10), Home/End jumping to minSplit/maxSplit (default 10–90), and the focus ring is the standard --color-action-primary-bg outline. Pointer drags use capture so a fast drag cannot escape the handle, and user-select is suppressed while dragging. Controlled (split + onSplitChange) or uncontrolled (defaultSplit); direction="vertical" stacks the panes.
AppLayout / AppSidebar
ds-app-layout — Full-page shell: an AppSidebar rail beside a <main> area that centres the page content (or, with layout="inset", sets it full width in an inset surface; the rail paragraph below owns the three layouts), with no header of its own (a page that needs one renders it inside the content). The layout keeps the rail's expanded state, seeded from defaultExpanded, and offsets the content to the rail's width. Pins data-theme="dark" by default (the historical behaviour); theme="inherit" drops the pin so the layout follows the surrounding theme. ds-app-sidebar — The side navigation component. Sidebar background: --color-bg-page-primary. Nav pills hold one inset in both states (12px margin + 8px padding), which makes the collapsed hover pill a circle clear of the 64px rail's edges and keeps every icon stationary through the expand/collapse. The transition choreography is deliberate: inner layout never changes between states — labels keep natural width with min-width: 0 so they truncate under the sweeping rail, gaps are constant, and only the rail's clipping width, explicit widths (toggle, profile-more), and opacity fades animate; width: 0 ↔ auto and gap snaps read as a bounce and are never reintroduced. layout sets how the rail meets the page, and AppLayout takes the same prop and passes it on. flush (the default) runs it edge to edge on --color-bg-app-shell behind a hairline divider, the content centred beside it on --color-bg-app-surface; App shells owns the modes. floating renders the rail as a glass card inset from the viewport edges (--color-bg-glass + 24px backdrop blur, --radius-600, --shadow-floating, inset via --ds-sidebar-float-inset, default 20px), the content still centred; the boolean floating prop is its deprecated alias. inset runs the rail flush on --color-bg-app-shell, and AppLayout sets the content full width in a raised surface on --color-bg-app-surface (--radius-600, the hairline and --shadow-floating) held off the top, bottom and far edge by --ds-shell-inset (10px); the rail's own 12px item inset is the gap on its side, so the selected item sits evenly between the rail's edge and the surface. The inset layout holds the viewport and the surface scrolls inside itself, its scrollbar hidden until it scrolls; the surface is clipped and an inner scroller does the scrolling, so the scrollbar never squares a corner. The content column is a size container (ds-app-layout-content), so a page restacks by the room the surface gives it rather than by the viewport. Below the mobile breakpoint the surface runs edge to edge in page flow. The side panel follows the layout: a second glass card beside a floating rail, and in the inset layout a continuation of the rail on the shell colour with one hairline divider between them, the surface starting beyond both. In panel mode the 80px labelled rail holds one seat through expand and collapse, as ThreadPanel does: the logo, every icon and the avatar keep their x and y, the rows glide between two explicit heights, and the caption under each icon crossfades with the inline label beside it. Items take an optional badge, a pill trailing the row while expanded, held a --padding-100 beat off the row's end. A count (a number, or digits with an optional +) takes the count badge pair, and while collapsed a presentational copy rides the icon's top-end corner (BottomNav's badge in size), the two crossfading with the rail; a word such as "New" stays a quiet --color-bg-container-tertiary pill and is hidden while collapsed; topSlot (under the logo row) and footerSlot (above the profile) host consumer content that fades out while collapsed without changing the rail's layout; persistentTopSlot keeps the top slot shown while collapsed, held at the nav rows' inset and clipped by the sweeping rail as they are, for content that draws its own collapsed form (a switcher holding its mark on the rail's axis while the rest fades, so nothing jumps), with data-expanded on the slot to key off; collapsedFooterSlot is the footer's collapsed form (the same controls at their smallest, such as a SegmentedControl's or ThemeToggle's cycle circle, stacked and centred on the rail's axis), sharing the footer's one cell so the two crossfade with the rail and the hidden form is inert, SiteHeader's actions / condensedActions rule. Collapsed, the rail's dead space is an expand target too (ThreadPanel's rule): a click anywhere that is not a control, or an open menu inside the rail, reopens it, and the cursor says so. The profile row composes Avatar at md, so a profile with no avatarUrl falls back to the person's initials and then to the generic glyph rather than showing the blank disc the hand-rolled box drew before it. An item or sub-item with an href renders as a real link through the rail's linkComponent or a LinkProvider, which is how it routes client-side (its onClick still fires too); accordion rows stay buttons. The active row carries aria-current="page". In the tablet band (769-959px) entering the band folds the rail to the icon rail once, through the same path a click takes so controlled hosts hear it — entry-only, so the visitor can re-expand by hand. Below the mobile breakpoint (768px — the Breakpoints section owns the number) the rail leaves the layout and a fixed hamburger trigger opens it as a modal overlay drawer on the shared behavior layer (dismissal stack, focus trap, counted scroll lock): the full expanded layout slides in from the left over --color-scrim, always expanded — an icon rail inside a modal would be labels withheld for no gain. While the drawer is open the logo row's collapse control becomes its close button — a close glyph labelled "Close navigation" — because a modal drawer has no icon rail to collapse to; it is driven by the drawer's own open flag, which can only be true below the breakpoint. The trigger and scrim exist only inside the mobile media query, so the overlay can never activate on wider viewports, and crossing back above the breakpoint while open closes it. showMobileTrigger={false} hides the built-in trigger for hosts that render their own in the page chrome. The rail shares its small furniture with ThreadPanel: the collapse toggle washes a --color-action-passive-bg-hover pill a --gap-100 step out from its icon and carries a Tooltip of its label (the drawer's close label on a phone).
Three levels. Navigation goes three levels deep and never further, in one of two shapes chosen once per sidebar with subNav. accordion (the default) keeps level 2 as the accordion under a top-level row; a sub-item that has children of its own shows a trailing chevron_right at --icon-size-500 and opens them as level 3 in a SidebarPanel fixed beside the rail. panel skips the accordion: a top-level row opens its sub-items straight into the panel, whose own accordion carries level 3, and the collapsed rail widens to 80px to name every destination, each icon sitting in a 56 x 32 --radius-pill indicator (the hover and selection fill, --color-action-passive-bg-hover) with a caption-scale label beneath it. The accordion holding the current page starts open, and so does the panel holding it (defaultPanelOpen, or controlled through panelOpen + onPanelOpenChange); activeTertiaryKey names the third-level page. While the panel is showing, the row feeding it carries the selection fill, so the rail shows which section is open, while aria-current stays on the real current page. Pressing that row again, or the panel's own collapse control, closes the panel. The panel tracks the rail's width (64, 80 or 280px) on the rail's own slow emphasized glide, sits one z-index step under it with hairlines either side (one, the divider beside the rail, in the inset layout), and slides a --padding-300 step out from under it at --motion-duration-slow; hidden, it stays mounted and inert so the slide runs both ways. floating makes it a second glass card one --gap-300 along from the first. On the collapsed rail in accordion mode, a row with children expands the rail and opens its accordion, since the accordion has nowhere else to go. Below the mobile breakpoint the panel never shows; the drawer drills in instead: a row with children replaces the nav with the panel's level as a second screen (a SidebarPanel with a back row naming the level above, sliding in from the right at --motion-duration-slow), focus moves to the back row, and going back returns focus to the row that drilled in. Opening the drawer lands on the screen that holds the current page. AppLayout passes subNav and activeTertiaryKey through and widens its content offset by the panel's 240px while the panel shows.
Generated marks. An AppSidebar item takes pixelAvatar to draw a generated PixelAvatar in its icon seat instead of icon, for rows that name a thing rather than a place (projects, workspaces, accounts): true seeds the character from the label, a string from that value, so a renamed item can keep its character. The sidebar spreads the inks across all of its avatar rows with distinctPixelInks, so neighbours differ in colour as well as shape.
SidebarPanel
ds-sidebar-panel — The secondary navigation column: one section's pages under a heading, optionally split into labelled runs (group on each item) and nested one level with children. It stands alone, and it is what AppSidebar opens beside the rail for a third level. Its row geometry repeats the sidebar's sub-items so the two columns read as one system: 40px --radius-pill rows with a --padding-300 edge inset and --padding-200 padding, --color-action-passive-bg-hover on hover and on the current page, --font-paragraph-* labels (the current page and a collapsed accordion hiding it step to the emphasis weight, so the reader finds their place without opening anything), and the sidebar's pill for badge: a count on the count badge pair, a word on the quiet pill. The heading is --font-overline-* in --color-text-secondary, uppercase, and wraps rather than truncates, because a clipped section name is the one thing a reader cannot recover; onCollapse adds a 32px left_panel_close control centred on its first line. Group labels are --font-paragraph-sm-* in --color-text-tertiary. Accordion rows toggle with an expand_more chevron at --icon-size-500 rotating 180 degrees, carry aria-expanded/aria-controls, and open through grid-template-rows: 0fr to 1fr at --motion-duration-slow / --motion-ease-emphasized; their children hang from a --border-025 tree line in --color-bg-container-border under the parent label's first letter, and a closed accordion is inert. The accordion holding activeKey starts open. onBack adds a back row above the heading (chevron_left, backLabel), which is the drill-in screen a drawer shows on small screens. The root is a <nav> named by the heading. Width is 240px, overridable through --ds-sidebar-panel-width. The collapse button takes collapseLabel.
EntitySwitcher
ds-entity-switcher — The "which one am I looking at" control: the company, unit, team, advertiser or account a screen is scoped to, as a mark, a name and a description over a panel to switch it. The data shapes the panel, never a prop: a list of leaves is one flat list, and any children make it drill in, each level led by its parent (described by wholeBranchLabel) so a whole branch can be picked, with a back row (backLabel, arrow_back) to climb out; the panel opens on the level holding the selection. Past searchThreshold entities (8) a compact Input searches every level, each result described by its path. The mark is a tile at --radius-200 on --color-bg-container-secondary: an entity's logo (an image URL) fills it, an icon (a Material Symbol name or any element, such as an inline SVG logo) sits inside it, and two initials stand in for both, on one of Avatar's six fills when the entity sets fill (the --color-avatar-* pairs, so entities read apart at a glance); it is decorative, the name carrying the entity. --icon-size-800 on the trigger, --icon-size-600 on a row. The trigger is a card that is a control: --color-bg-container-primary with its --color-bg-container-border hairline drawn as an inset shadow so it takes no room, --radius-300, the passive wash on hover and while open, and the mark one --padding-300 in. The panel is Popover's menu surface at the trigger's width, measured on open, and its rows are overlay rows (no fill at rest, --color-action-passive-bg-hover at --radius-200 on hover and focus): a row with children trails chevron_right and opens its level, a leaf picks and closes, the selected leaf carries ChosenMark, and a disabled entity sits at opacity: 0.4. Names and descriptions hold one line each and truncate. collapsed, or a host rail marked data-expanded="false" (AppSidebar's persistentTopSlot), keeps the mark in its seat and fades the box and the words, so nothing moves as the rail sweeps; the mark's tile then carries hover and the focus ring, and the panel opens beside it (end) at an expanded rail's width; collapsed={false} opts out of following a rail. The list is a role="menu": leaves are menuitemradio with aria-checked, rows with children menuitem with aria-haspopup, arrow keys move between rows, Home and End jump, and the inline arrows (read through the reading direction) drill in and climb out. The trigger is named switcherLabel plus the selected entity's name. Selection and the panel's open state are each a controlled/uncontrolled pair (value/defaultValue/onValueChange, open/defaultOpen/onOpenChange).
ProgressiveBlur
ds-progressive-blur — A backdrop blur that dissolves along one side instead of ending in a hard line. A single backdrop-filter is uniform across its box, so a tint fading to transparent over one blur still stops at a visible edge; a mask-image on the same layer multiplies the filtered backdrop by its alpha as well as the paint, which is what lets the blur itself dissolve. So the component is four __layer spans with blur radii rising 2, 6, 12 and 24px, each masked over a band that shrinks as the blur grows (the 2px layer fades across 55% to 100% of the box; the 24px one is gone by 40%), frosted at the anchored side (top by default, or bottom) and sharp at the far one. The radii are the effect's own tuning, not a spacing scale. tinted lays --ds-progressive-blur-tint (the override hook, defaulting to --color-bg-page-primary, so the edge reads as one with the page floor in both themes) over the blur on its own ::after layer, stepping through color-mix to transparent. Only the tint fades, on --motion-duration-slow / --motion-ease-standard: transitioning opacity on a backdrop-filter makes the browser re-rasterise the blur, which snaps in at the end of the fade. The blur sits on child layers, never on the root, because a backdrop-filter on an ancestor makes it a backdrop root and any glass inside the host would then sample only that subtree. The root fills its positioned parent (position: absolute; inset: 0), ignores the pointer, and is hidden from assistive technology; a host that needs it viewport-fixed or taller than itself restyles the root through className. The mask keywords are its one sanctioned off-token colour. Presentational and server-renderable.
ShaderField
ds-shader-field — A WebGL2 canvas summing soft Gaussian light sources into an ambient field of colour. Each source names a semantic colour token, read from the canvas's computed style at runtime, so the field re-themes with data-theme and with any scoped override — a consumer's palette reaches it for free. Colours are decoded to linear RGB and blended there, so midpoints keep their saturation; theme crossfade is --motion-duration-slow, and the reveal is --motion-duration-slower / --motion-ease-entrance, matching the page's own entrance.
The component owns the canvas and nothing else: position: absolute; inset: 0 in whatever positioned ancestor the caller provides, pointer-events: none so a full-bleed layer never swallows a click. Placement, clipping and any mask belong to that ancestor — baking one in would make it unusable anywhere but a page background. The draw loop runs only where someone can see it: it pauses when the canvas is scrolled out of view (resuming 200px before it scrolls back in, so it is already moving when it appears) and in a hidden tab, and under prefers-reduced-motion it paints single frames. A page can carry several fields, the site's home page a background and a grid of shader tiles, and a phone GPU should never draw every one of them at frame rate when only one is in view. The default composition never samples --color-action-primary-bg: a full-viewport decorative field is exactly the use that would dilute the action colour, so the last source takes --color-core-ui-secondary.
Eight parameters — intensity, warp, scale, speed, grain, streak, react, crop — pass as a partial merged over the defaults. streak stretches sources along a fixed diagonal, discs at 0 to light streams at 1. grain doubles as the dither that kills 8-bit banding, so it is never truly off. react is the cursor wake, shipping at 0 — built and dormant, not absent; raising it steps the loop from 30fps to 60fps only while a wake is alive. crop decides what a narrow container does to the composition: at 0 — what a CSS blob layer does — the whole field is fitted into it, so a phone shows every source shrunk to phone scale; at 1 the field holds the 1440px scale it was composed at and the viewport crops into it instead. Values between blend the two, keeping enough shrink that colours still bleed together on a small screen. The default sits between the two, which suits a full-viewport background (the site runs the default); set it to 0 wherever seeing the whole composition is the point. A negative source weight absorbs light, cutting a shadow that occludes whatever shines behind it. BLOB_COUNT (8) sizes the shader's uniform arrays as a compile-time constant; passing fewer sources parks the unused slots off-field.
Two behaviours are the component's rather than the caller's, and both are why it can be dropped in. It honours reduced motion in JavaScript — the tokens-motion.css guard cannot see a requestAnimationFrame loop, so this draws one static frame and never starts the loop (see Do's and Don'ts). And it always reports how it resolved, via onStatusChange and a data-status attribute: pending, active, unavailable. No WebGL2, a blocked or lost context, a compile failure, a stalled first frame, and enabled={false} all land on unavailable, so one fallback covers every failure. pending is the state worth handling: paint neither, since swapping a fallback out a frame later reads as two backgrounds in sequence — a 1.5s watchdog bounds the wait so it can never be terminal. It renders no fallback of its own; what to paint instead is a design decision, not a rendering one. The canvas is aria-hidden.
ShaderScene
ds-shader-scene — A WebGL2 scene, where ShaderField is a wash: scene="horizon", a planet's limb at night under a fixed sun, or scene="rift", a tear of light opening across the dark. Both take their colours from the background field's roles (--color-ambient-*, with the page ground from --color-bg-page-primary), read from the computed style at the scene and re-read when data-theme, data-brand or the root's inline style changes, so a theme, a preset and a DESIGN.md import reach it with nothing to configure. Like ShaderField it owns the canvas and nothing else: position: absolute; inset: 0 in the caller's positioned box, pointer-events: none, aria-hidden, and the page ground painted under the canvas, so a machine without WebGL2 shows a plain page.
Each scene's settings pass as a partial in params, merged over its defaults; SHADER_SCENES publishes every key with its label and range so a host can draw its own controls, and shaderSceneStills bakes a still per scene for a picker. Five finishing passes run over either scene (scrim, vignette, fadeTop, fadeBottom, grain), each switched and tuned in passes; the fades melt the scene into the page ground, and the scrim is for text that sits on it. A new prop moves the scene on its next frame without rebuilding the GL program; liveRef goes further for a host that tunes by drag, a ref the scene reads every frame in place of the props, so a slider writes the ref and renders nothing (the drag rule in Components).
Both scenes are night scenes, adding light to a dark ground. On a light page each computes its light against a dark ground of its own and lays it onto the page as a pale tint, a soft dawn rather than a dark block. It never sets data-theme on itself or an ancestor, which would hide a preset's colours. It honours reduced motion in JavaScript, as ShaderField does: a still frame that still answers its settings; speed={0} holds it still on purpose. And it reports how it resolved in ShaderField's three states, through onStatusChange and data-status: pending, bounded by the same watchdog, then active once a frame paints, or unavailable when enabled={false} or WebGL2 cannot run it, leaving the page ground for a caller's fallback. Its loop runs only near the screen, stopping when it scrolls away and starting 200px before it returns, so a page of stages draws only the ones in view. It is held to ShaderField's budget: 30fps, and at most one drawing-buffer pixel per CSS pixel, since soft light has no detail a 2x screen's extra pixels would show and they quadrupled the fill.
Toolbar
ds-toolbar — A grouped strip of controls sharing one pill shell: Buttons, CircularButtons, SegmentedControls, split into clusters by ToolbarSeparator hairlines (--border-025 on --color-divider, stretched to the control height with --gap-050 margins). The shell is --radius-pill on --color-bg-container-primary with a --color-bg-container-border hairline, --gap-100 between controls, and --padding-150 all round — the padding clears the focus ring's full reach (2px offset + 2px outline), so a focused control's ring seats inside the shell instead of slicing its border. variant="floating" is the glass pill for controls hovering over content — --color-bg-glass, a 24px frost (a component-local effect length), --shadow-floating — deliberately unpositioned: the consumer's layout decides where it floats (the site's StageControlBar is that consumer's shape). orientation="vertical" turns the pill into a capsule and rotates the separators. Announces as role="toolbar" with a required accessible label; arrow keys walk focus between the controls (Home/End jump to the ends) while every control keeps its natural tab stop, and a control that owns its arrow keys (a SegmentedControl) keeps them by preventing default before the event bubbles.
Nav
ds-nav — Deprecated: the earlier plain top bar, superseded by SiteHeader and kept working for existing consumers. A top navigation bar: a 78px-tall, max-width 1440px flex row with a brand slot (icon + --font-paragraph-emphasis-* text, --gap-500) on the left and a horizontal ButtonGroup plus optional trailing content (e.g. the theme ToggleSwitch) on the right at --gap-1000. Purely compositional — active states come from the Button configs passed in. The landmark is labelled "Main" by default so it stays distinguishable from any other <nav> on the page; a passed aria-label overrides it, and other native attributes spread onto the <nav>. New work uses SiteHeader, which adds section mega menus, the sticky progressive blur and the fold to a drawer.
NavList
ds-nav-list — Vertical navigation link list for drawers and side menus, up to three levels deep. Every row is a real link rendered as a full-width, left-aligned tertiary Button (level 1 at the default size, deeper levels compact), so shape, padding, typography, and hover/pressed states have one home in the Button spec and stay identical to every other button. The current page gets aria-current="page" plus the tertiary active background (--color-action-passive-bg-active) — the same treatment the showcase sidebar uses. A collapsible item adds a separate 40px --radius-pill chevron toggle (expand_more at --icon-size-500, rotating 180° when open) wired with aria-expanded/aria-controls — tapping the label navigates, tapping the chevron expands. Every row reserves that same 40px height, so leaf rows and toggle rows sit on one vertical rhythm at the list's --gap-050 spacing. Sublists indent one --padding-1000 step (then --gap-500), take the same --gap-050 below their parent row, animate open via grid-template-rows: 0fr → 1fr at --motion-duration-slow / --motion-ease-emphasized, and are inert while collapsed so their links leave the tab order. Expansion is uncontrolled (defaultExpandedIds) or controlled (expandedIds + onExpandedChange), single-open by default; collapsible: false renders a permanent, always-visible group. The branch toggle is named by expandLabel/collapseLabel, each given the row label.
MegaMenu
ds-mega-menu — A wide navigation panel: a grid of link rows, each an icon tile, a label and a description clamped to two lines, beside an optional showcase card in a column of its own. It is the panel only. The host places it (under a header bar, usually), owns the triggers, the hover delay and the dismissal, and so owns open: the panel is controlled by design, with no trigger of its own to hold the state for. SiteHeader is the reference host. A closed panel fades out on --motion-duration-base / --motion-ease-standard, turns visibility: hidden once the fade has run, and is inert throughout, so its links leave the tab order the moment it closes.
The shell is the floating glass CommandPalette wears (--color-bg-glass, a --border-025 --color-bg-container-border hairline, --shadow-floating) at --radius-400 and --padding-1000, with its blur raised to 32px: the panel is a shallow strip that lands on whatever the page opens with, display type most of it, and at the shell's 24px a heading that size reads straight through the row labels. The grid runs four rows to a column and wraps into further columns, so a long menu grows sideways and never into a scroll. Rows are the overlay row family: no fill at rest, --color-action-passive-bg-hover on hover and focus at --radius-200, and the row matching currentHref (or a parent of it) holds --color-action-passive-bg-active with aria-current="page". The icon tile (--icon-size-600 glyph, or a logo image at the same size) sits on --color-bg-container-secondary, which already differs from the hover wash; on the current row, whose wash is that same fill, the tile takes --color-bg-container-tertiary. An icon a consumer names goes through mirrorClass.
The showcase is one link that is the whole card: a cover drawing fills it and absorbs whatever height the grid runs to, so the columns always bottom-align, with a floor that keeps a short grid's card near 4:3. The overline, the title with its trailing arrow_forward (mirrored in right-to-left, nudged along the reading direction on hover) and the description sit over a rise of --color-bg-page-primary from the card's foot. The column carries the divider as an inline-start hairline. Below a 720px panel (a container query on the panel itself, never the viewport) the showcase stacks under a single column of rows and its floor grows to a wide band. Every link renders through linkComponent, falling back to <a>; the panel is server-renderable, so a client host resolves a LinkProvider and passes it down. onNavigate fires on any link, so the host can close the panel.
SiteHeader
ds-site-header — A site's top bar: the brand at the start, a pill per section beside it (--gap-1000 from the brand plus the first pill's own inline padding sets the gap, so the pills sit by the brand rather than centred on the page), and the controls at the end: actions, which stay in the bar at every width (a search button, a theme toggle), with condensedActions as their narrower forms on the condensed step (The fold, below), then wideActions, which show only while the pills fit (a call to action). The menu button takes the place of wideActions once the bar folds, so the persistent actions never move. The bar is --ds-site-header-height tall, an override hook defaulting to the default control height plus --padding-1000.
Pills. Each section is a link at --control-height-compact on --radius-pill in the paragraph-sm emphasis face, so a caret's icon box never makes one pill taller than its neighbours. Hover takes the tertiary family's --color-action-passive-bg-hover wash, as a tertiary Button, a NavList row and the menu's own rows do (a container fill can equal a light theme's page floor and vanish), and an open menu holds that wash and nothing more, since opening a menu is not a selection. The current section, given by its current flag or worked out from currentHref (its own page, a page under it, or any page its menu lists), is the selected item of an exclusive set and takes the action selection pair, SegmentedControl's active segment, hovering to --color-action-primary-bg-hover; a neutral fill read as the hover state over the glassy blur. A section with a mega carries an expand_more caret that turns over while its menu is open.
Menus. One MegaMenu hangs --gap-200 under the bar across its full width, its content the open section's. It belongs to the bar and spans it rather than anchoring to a control, so it rides the bar's own box instead of useAnchoredPosition. Hovering a section's pill or panel opens it, leaving either closes it after MOTION_HOVER_HIDE_DELAY_MS so the pointer can cross the gap, and focusing the pill opens it too. The pill keeps aria-expanded and aria-controls (a disclosure, not an ARIA menu, since the panel holds links), ArrowDown on it moves focus to the panel's first link, and a click follows its link and closes the panel. The open panel is a light-dismiss layer on the shared stack: an outside press or Escape closes it, and Escape from inside the panel hands focus back to the pill without reopening it.
Sticky. By default (sticky) the header is position: sticky at the top on --layer-sticky, so it rides up with whatever sits above it and then holds, with no second bar and nothing in the page shifting. Behind it is a ProgressiveBlur restyled viewport-fixed and full width, reaching --ds-site-header-blur-reach (--gap-1500) below the bar, inside the header's stacking context at z-index: -1: never a filter on the header itself, which would make it a backdrop root and kill the panel's own glass. The blur is always on; its tint fades in once the page has scrolled tintAfter pixels (48 by default), read on a scroll listener throttled to one check a frame. sticky={false} lets the header scroll away with no blur.
The fold. The bar folds on the room it has, not on a viewport breakpoint: a ResizeObserver compares the width the full row needs (brand, pills, both action clusters and the gaps between them) against the bar's own width, so any number of sections, any label length, a theme with a wider face or a docked panel narrowing the page all fold it at exactly the right point with nothing to tune. The fold has a step before it: condensedActions are the same controls in their narrower forms (an iconOnly CommandPaletteTrigger, a cycle ThemeToggle), and when the full row stops fitting they take the place of actions and the header adds ds-site-header--condensed; the bar folds only when the condensed row does not fit either, or, with no condensedActions, as soon as the full row stops fitting. Both sets stay in the DOM, the one not showing hidden, out of flow and inert like the folded pills, so the row is measured with each on every check and the two steps never chase each other. Before the first measurement, the initialFold stand-in shows the condensed set wherever it folds the pills. The drawer's scroll lock takes the page scrollbar away and widens the bar, so while the drawer is open the header measures against the bar as it was with the scrollbar, and a header within a scrollbar of its fold does not unfold and close the drawer it just opened. Folded, the pills and wideActions stay in the DOM but hidden, out of flow and inert, so they keep their width for the next measurement and leave the tab order, and React state changes only when the answer flips. A compact secondary neutral CircularButton (drawerOpenLabel, "Open menu"), worn in the track recipe the CommandPaletteTrigger, the ThemePicker's trigger and the cycle ThemeToggle wear so the circles at the bar's end read as one set (the fill steps up on hover, the glyph holds --color-action-icon-default), takes their place and opens a start-side Drawer (drawerTitle, "Menu"; drawerCloseLabel, "Close menu") holding a NavList of the same sections, each section's menu items its sub-list and the current section expanded, with drawerFooter in the drawer's footer for the controls wideActions carried. The drawer is the library's modal, so focus, scroll lock and Escape are its contract, not the header's. It wears the command palette's floating glass (--color-bg-glass under a 24px blur) at min(320px, 80vw), and the header lays it over the bar it replaces: as it opens, the header measures the bar and the menu button, so the drawer's brand row (Drawer's heading, with drawerTitle still naming the dialog) lands on the bar's brand and the drawer's own close button stands on the scrim exactly where the menu button was, at its 24px glyph, white over the always-dark scrim, fading in once the panel has slid home while the menu button underneath hides. The close stays the drawer's own button, inside its focus trap, and focus returns to the menu button on close. The footer row starts at the panel's inline start. A fold closes any open panel, and unfolding closes the drawer. The <nav> is named by navLabel ("Primary"), and every link renders through linkComponent, resolved from a LinkProvider when unset. Before the first measurement (a server-rendered first frame), initialFold stands in: below the tablet step (the default) or the wider mid one the bar starts folded by CSS alone, so a phone never sees the pills overflow, and never opts out. The drawer's state is a controlled/uncontrolled pair: drawerOpen with onDrawerOpenChange, or defaultDrawerOpen.
BottomNav
ds-bottom-nav — The tab bar of a mobile app: three to five destinations along the bottom of the screen, icon over label, the selected icon drawn filled (Material Symbols' FILL axis, the convention on every platform). activeKey marks the current destination with aria-current="page"; onValueChange reports a press; items with an href render as links. The landmark is named "Tabs" by default, distinct from Nav's "Main" so a screen carrying both announces two different navs; aria-label overrides it. search is a destination set apart from the rest: its glyph defaults to search, and its icon repurposes it (an assistant launcher, say). A badge is a count or, as true, a dot on the icon's top-end corner, with visually hidden text announcing it after the label: badgeLabel(badge), ", 3 new" for a count and ", new" for a dot by default. fixed pins the bar to the viewport bottom; the home-indicator safe area is padded in every placement. platform picks one of three renderings of the same markup:
default, the Rift bar and fully on-token: a 64px bar on--color-bg-page-primaryunder a--border-025hairline, caption labels in--color-text-secondary, and the selection in--color-action-primary-bg, since the current tab is the selected item of a mutually exclusive set.ios, the iOS 26 Liquid Glass capsule: a 62pt glass pill floating 21pt in from the screen edges, a lighter glass lens behind the selection that slides between slots on--motion-ease-spring, 10pt medium labels in the system face, and the tint on the action token.searchbecomes its own glass circle beside the bar (elsewhere it joins the bar as the last tab), andminimizedfolds the capsule to the selected slot alone, the way the system bar shrinks while a page scrolls down.android, the Material 3 Expressive navigation bar: 64dp on--color-bg-container-secondary(the surface-container role), a 56 x 32dp pill indicator in a tint of the action colour growing out from the icon's centre on selection, and 12/16 medium labels tracked 0.5.
The platform variants are the system's one sanctioned departure from its own type and material rules, because their job is to read as the real system chrome in a mock or prototype; a shipped native app uses the native component. Their metrics, the platform faces (the device's system-ui face on iOS, Google Sans Flex then Roboto on Android, neither bundled), the glass fill, blur, saturation, white rim and shadow, and the platforms' own badge reds are restated from each platform under the platform directive category, scoped to those variant blocks. Colour roles still come from the tokens wherever the platform has a role for them, so a theme preset still reaches the accent and the surfaces. On the web the glass is frosted, not refracted: Apple's lensing needs shader access to the content behind it, which no browser gives a stylesheet.
TopAppBar
ds-top-app-bar — The header of a mobile screen: a leading control (navigation of menu, back or close, reporting through onNavigate, labelled "Open menu", "Back" or "Close" unless navigationLabel says otherwise), the title with an optional subtitle, and trailing icon actions: the first three show as buttons, and any more join a DropdownMenu behind a "More" button, ahead of the overflowActions listed there (the button's label set by overflowLabel). Overflow rows are menu buttons, so the overflowActions type leaves out href and badge rather than dropping them silently, and an action that spills into the menu renders as a plain row that routes from onClick. An action's badge is a dot, announced as ", new". size="medium" and "large" add an expanded title under the row that folds away as content scrolls under the bar (grid-template-rows at --motion-duration-slow / --motion-ease-emphasized) while the bar's own title fades in; exactly one of the two copies is the heading (headingLevel, default 1) at any moment, and the other is aria-hidden. The bar watches its scrollTarget (the window by default), or takes scrolled from the host. It is sticky to the top of its scroll container by default, and pads the status-bar safe area. align centres the title (the iOS default) or starts it at the leading edge. A badged action's accessible name is badgeLabel(label), "<label>, new" by default.
default: a 56px row on--color-bg-page-primary, 40px--radius-pillicon buttons with the passive hover, the title in--font-title-body-*, the expanded title in--font-heading-2-*(medium) or--font-heading-1-*(large), and a--border-025hairline that is transparent until content scrolls under, so the scrolled state moves no geometry.ios: the iOS 26 navigation bar. No bar fill and no hairline: the leading control is a 44pt glass circle and the trailing actions share one glass capsule, the inline title is 17pt semibold and the large title 34pt bold, and once content scrolls under, the scroll edge blurs and fades it toward the top of the screen.android: the Material 3 Expressive top app bar. A 64dp row with 48dp targets around 40dp state layers, a 22/28 title and 12/16 subtitle, medium and large flexible titles at 28/36 and 36/44, and the surface lifting from the page role to--color-bg-container-secondaryon scroll.
The platform rules are BottomNav's: the same sanctioned platform exception, the same faces, and the tokens wherever the platform has a role for them.
AnchorNav
ds-anchor-nav — "On this page" list of anchor links for long pages. Optional header row (Material Symbol at --icon-size-500 + --font-paragraph-emphasis-* title) above a list carried on a --border-025 --color-divider left rail. Links are --font-paragraph-* in --color-text-tertiary with --padding-150/--padding-400 padding; the active link turns --color-text-primary at em weight, paints a --border-050 indicator in --color-text-primary over the rail, and carries aria-current="location". Uncontrolled, a scroll listener marks the last section whose top passed the offset prop; pass activeId to control the highlight instead. Focus-visible ring in --color-action-primary-bg.
The floating variant trades the inline rail for a minimap that costs no column width: a right-aligned stack of one --border-050-tall, --radius-pill line per section (16px in --color-divider; the active line stretches to 24px in --color-text-primary) at --gap-200 spacing inside a --padding-200 hit area. Hover, keyboard focus, or a tap expands the full header + list as a panel over the lines — Popover's floating-surface recipe (--radius-200, --border-025 --color-bg-container-border hairline, --color-bg-page-primary fill so nothing shows through, --shadow-floating), right-anchored and vertically centred, capped at 70vh with internal scroll, sliding in one --gap-050 at --motion-duration-fast / --motion-ease-standard. The links stay focusable while collapsed (opacity, never visibility: hidden), so tabbing in opens the panel via :focus-within; Escape or a click elsewhere closes a tapped-open one. The lines are aria-hidden decoration — the list is the semantics in both variants. The caller positions the nav itself, typically fixed on the right page edge.
Breadcrumb
ds-breadcrumb — Location trail as a <nav aria-label="Breadcrumb"> ordered list. Items are links in --font-paragraph-sm-* --color-text-secondary separated by chevron_right Material Symbols at --icon-size-500 in --color-text-tertiary (the 20px step matches the 20px line-height of the labels either side, so the chevron centres on the trail); the current (last) item is plain text in --color-text-primary at the -em weight. maxItems collapses the middle of long trails to an ellipsis, always keeping the first and the trailing items. Link focus rings use --color-action-primary-bg.
Tabs
ds-tabs — A role="tablist" row built from a tabs array; the selection follows the controlled/uncontrolled pair convention (value/defaultValue/onValueChange, with activeTab and onTabChange kept as deprecated aliases), and the tablist is named "Tabs" by default through the native aria-label; default and compact sizes, and fullWidth to share the row equally. Arrow keys, Home and End move between the enabled tabs. It renders the tab list only: the consumer renders the panel. Use for grouping related content on a single page (e.g., component page showing Light/Dark/Mobile views). A tab's panelId names its panel, written as the tab's aria-controls.
Accordion
ds-accordion — Collapsible content sections in a single card surface: --color-bg-container-primary behind a --border-025 --color-bg-container-border hairline at --radius-300, row dividers inside. Headers are --font-paragraph-emphasis-* buttons with a rotating expand_more chevron and --color-bg-container-secondary on hover, the collapsible-section convention the agent cards set; panels hold arbitrary content at --font-paragraph-*. Single-open by default, multiple allows several; the open set follows the controlled/uncontrolled pair convention (expanded/defaultExpanded/onExpandedChange). Headers carry aria-expanded and are wired to their panels through ids scoped to the instance, so two accordions with the same item ids never collide; a closed panel is inert, so its links leave the tab order.
TreeView
ds-treeview — Collapsible hierarchy for files, folders, and nested structures, rendered as a WAI-ARIA tree (role="tree" on the root <ul>, role="treeitem" on each <li>, children in role="group"). Rows carry a chevron_right chevron that rotates a quarter turn on expand (--motion-duration-fast / --motion-ease-standard; leaves render a spacer in the chevron column so labels align), a Material Symbol at --icon-size-500 (folder default for branches, draft for leaves, per-node icon overrides), and a --font-paragraph-sm-* label that ellipsises rather than wraps. Indentation is a per-depth custom property: each row's --ds-treeview-depth steps padding-left by one --gap-400. Hover takes --color-action-passive-bg-hover; the selected row (ds-treeview__row--selected) takes --color-action-passive-bg-active with the label bumped to --font-paragraph-sm-emphasis-weight; rows round at --radius-200. The tree items themselves are focusable via a roving tabindex (one tab stop, never nested buttons): ArrowDown/ArrowUp walk the visible rows, ArrowRight expands or steps into children, ArrowLeft collapses or moves to the parent, Home/End jump to the ends, and Enter or Space selects (click on a branch row also toggles it). Expansion (expandedIds/defaultExpandedIds/onExpandedChange) and selection (selectedId/defaultSelectedId/onSelect) each follow the controlled/uncontrolled pair convention. Each treeitem is named by aria-labelledby pointing at its own label, so a branch never announces its whole subtree as its name; expanded state styling keys off aria-expanded, so visuals and semantics cannot desync.
Carousel
ds-carousel — Sliding content viewport. Navigation arrows are 36px --radius-pill bordered buttons on --color-bg-page-primary (no shadow — the arrows are not floating surfaces); dot indicators mark the active slide with --color-action-primary-bg. Supports autoPlay with configurable interval, loop, and keyboard navigation (arrow keys); the current slide follows the controlled/uncontrolled pair convention (slide/defaultSlide/onSlideChange), and every accessible name it writes (arrows, slides, dots) is a *Label prop with an English default. Autoplay pauses on hover or keyboard focus and never starts under prefers-reduced-motion (a JS-side check; see Do's and Don'ts). The dots are a roving-tabindex role="tablist": one tab stop, arrows move and select. Slides are the component's children.
ImageCompare
ds-image-compare — Before-and-after comparison of two images sharing one frame: a --radius-400 container on --color-bg-container-secondary under a --border-025 --color-bg-container-border hairline, sized by aspectRatio (default 16 / 10) with both images covering it. The after image is the base layer; the before layer sits on top clipped by clip-path to the divider position, so the two stay pixel-aligned as the split sweeps. Dragging anywhere in the frame moves the split (pointer capture, touch-action: pan-y, so vertical page scroll survives over the frame on touch); the handle — a 32px --color-bg-page-primary disc under --shadow-floating riding a --border-050-wide divider line — is a role="slider" with arrow keys (±1), PageUp/PageDown (±10), and Home/End. Corner labels (beforeLabel/afterLabel, defaults Before/After, hidden via showLabels) render as inverse pills in --font-paragraph-sm-emphasis-*. Position is controlled (position) or uncontrolled (defaultPosition, 50) with onPositionChange. The handle is named by handleLabel and announces handleValueText.
Table
ds-table — Data table with header row and body rows; presentational only (no sorting, selection, or row expansion). A column's align is start by default; start and end follow the reading direction (a number column set to end sits right in English and left in Arabic), while left and right keep their screen side. Header: --font-title-body-* (weight 600). Body: --font-paragraph-*. Every colour is the table family (Table surfaces, above): row dividers on --color-table-divider, hover on --color-table-row-hover-bg. The plain table takes the background of whatever it sits on. bordered wraps it in a --border-025 --color-table-border outline with --radius-300 over --color-table-body-bg, so it stands off the surface it sits on (brighter in light, a little darker in dark), with the header band on --color-table-header-bg: the look every markdown table shares, which Prose draws for markdown in chat and docs. Three additive hooks carry semantics a consumer can set: a column's sort (asc or desc, DataTable's direction) is mapped to its header's aria-sort, a rowHeader column renders its cells as <th scope="row"> drawn as plain cells, and a row's selected holds the --color-table-row-selected-bg tint over hover and stripe. The bordered wrapper is the table's scroll container: while the table overflows it, the wrapper becomes a role="region" named by the caption with tabIndex={0}, so a keyboard user can scroll it, and while it fits it is a plain div (the internal ScrollRegion behaviour, a client boundary, so Table stays server-renderable). striped fills every even body row with --color-bg-container-primary-semi, keeping the same hover tint. size="compact" tightens the cell padding and sets header and body cells in --font-paragraph-sm-*. columnDividers adds a --color-table-divider line between columns, beside the lines between rows. A bordered table's visible caption is drawn above the outline, where the plain table's sits, with the <caption> itself kept in the table and visually hidden, so the table and its scroll region keep their name: a caption inside the outline would sit under the border and scroll with the body.
DataTable
ds-data-table — The wired version of Table: sorting, search, row selection and pagination, on the client or handed to a server (see Server mode below), assembled around the presentational pieces (Table's bordered markup and classes, Pagination, Checkbox, Input, EmptyState), so a working data view is one component rather than an afternoon of plumbing. TanStack Table (v9, headless) runs the row model underneath: the search, the sort and the selection state, with rift's own comparator and search test so the order and the matches are what they always were; every type the component exposes is rift's own, so the engine's version never reaches the API. It is generic over the row type (DataTable<R extends DataTableRow>, defaulting to DataTableRow), so a row may carry more than its values and a column's render reads it typed, and it takes its ref as a plain prop. Rows carry raw values keyed by column; a column's render turns them into cells (Badges, Buttons) while sorting and search keep reading the raw value underneath. Chrome only in this component's own CSS — rows and cells are entirely Table's. The sorted header carries aria-sort, and its button is named by the header text alone (sortLabel), so the state is not read twice. A rowHeader column names each row, and its value names the row's checkbox ("Select Ada Lovelace", selectRowLabel; the first column stands in when none is marked, the id only when that is empty). Selected rows hold the table's selected tint. What changes without moving focus is announced through one polite region, mounted empty: a sort (sortAnnouncement), the result count once a search settles (resultsLabel, debounced by MOTION_ANNOUNCE_DEBOUNCE_MS), a page change (pageAnnouncement) and select-all (selectedLabel); a single row's checkbox speaks for itself. The visible footer count is resultsLabel and selectedLabel, and is not itself live. The built-in pager and empty state are localised through paginationLabels (the arrows, the readout and the landmark's name) and emptyStateLabels (the title and description), each merged over the English defaults; a consumer emptyState replaces the whole node.
Sortable headers render the label inside a button with a trailing sort glyph (swap_vert idle, arrow_upward/arrow_downward active, --icon-size-500) that fades in on hover/focus and stays at full opacity while active; clicks cycle ascending → descending → unsorted, and the button's aria-label narrates the current state. Multi-sort: with multiSort, a Shift-click adds the column after the current sorts (or cycles it in place among them) while a plain click still makes it the only sort, and a header menu offers Add to sort and, with several sorts, Remove from sort. While more than one column sorts, each sorted header shows its rank beside its glyph in caption type (in its floor, so ranks never squeeze the label); aria-sort marks only the first sort's header, and the others say their rank in the sort button's name (sortRankLabel, "Owner, sort 2, ascending"). Sorting compares numbers numerically and text with localeCompare (numeric-aware). The toolbar puts a consumer toolbar slot (filter Dropdowns) on the left and the built-in search Input (search icon, capped at 240px) on the right; searching matches every column's raw value and resets to page 1. Selection adds a 40px checkbox column with an indeterminate select-all scoped to the visible page. The footer pairs a tabular-nums readout (--font-paragraph-sm-* tertiary; result count, or "N selected" once a selection exists, in an aria-live region) with Pagination. No matches renders the bordered EmptyState. Density is one setting: size sets the rows and the table's own controls together, so a default table has the default-height search field and the numbered pager and a compact one the compact field and the "Page X of Y" readout, which a table narrower than a phone takes at either density, since a phone's width cannot hold the numbered pages; the controls inside rows (checkboxes, grips, menu and action buttons) stay compact at either density, so a row is as tall as its text. Paging: pageSize (or defaultPageSize) turns the pager on, and both the page (page/defaultPage/onPageChange, counting from 1) and the size (pageSize/defaultPageSize/onPageSizeChange) are pairs; pageSizeOptions puts a compact Rows per page Dropdown before the pager, with a caption label. A search, a filter, a sort or a new page size starts over at page 1 (reported once, not per keystroke), and a page past the last shows the last in the same render. On a phone (the table's own width under BREAKPOINT_PHONE_PX, measured, so a table in a narrow panel counts) phoneLayout picks the reading: scroll, the default, keeps the rows and pins the row header to the start while the rest scroll sideways, its width capped at 40% of the table so the columns passing it keep room, without touching the pinning the reader set; stack draws each row as a card on the body fill, divided like rows: the checkbox, the row header as the title in emphasis type and the actions at the line's end, then every other column as a caption label beside its value, the label taking a quarter of the card so the value sits close and wrapping a long header rather than cutting it. The cards always fit the frame: Table's phone rule, which holds a column table at its max-content width so it scrolls, never applies to them. The header row is visually hidden and the cells state their roles, since a card's display strips table semantics in some browsers; widths, pinning and row grips step aside. Grouping and totals: a groupable column can group the rows (grouping/defaultGrouping/onGroupingChange, column keys, of which the first is drawn), from its header menu (Group by this column, Ungroup) or a TableToolbar's Group control. The matching rows gather by the column's value, groups in the column's own order (its sort direction when it sorts, a status by its options' order, the rows with no value last under "No value") and rows keeping the sort within each group; paging runs over the gathered rows. Each group is its own tbody, led by a group row on the header's tint that takes no hover: a th scope="rowgroup" holding an aria-expanded chevron toggle (rotating a quarter turn), the value drawn through its column's type and the count in caption type, its label running past the first column over the empty cells beside it, then each column's aggregate (sum, avg, min, max, count, countDistinct, range or a function) in that column's cell, formatted through its type. Groups start open and fold for the session; the group row's checkbox selects the group, indeterminate while partly selected; a grouping owns the row order, so row grips step aside. Native grouped bodies keep table semantics for a read-only table, where D4's treegrid would switch them off. totalsRow adds a footer sticky at the table's foot on the header's tint, divided from the rows like the header: each cell a menu picking its calculation over every matching row (totals/defaultTotals/onTotalsChange, Notion's Calculate set, the numeric ones only on a numeric column), showing the calculation's name in caption type beside its result, an unpicked cell offering Calculate only while the footer is hovered or focused (in the layout at rest, so nothing moves). Stacked, a group row is the cards' heading. Row details: renderDetail adds a chevron column (after the checkbox, its header hidden as "Details") and an opened row shows what it returns beneath it, in one cell across every column, the panel as wide as the visible table and held to the start edge so it stays in view while the columns scroll; it renders only once opened, takes no hover tint, and opens with a height ease (grid-template-rows from 0fr), which the reduced-motion guard makes instant. The open rows are a pair (expandedIds/defaultExpandedIds/onExpandedIdsChange), expandMode="single" closing the others as one opens; the chevron is an aria-expanded toggle with aria-controls naming the details (toggleDetails), and in a grid the right arrow on a row header opens a closed row and the left closes an open one, as a tree row does. Stacked, the details close their card. Saved views: a DataTableView is one serialisable object (version: 1, then any of the sorts, filters, search, hidden columns, column order, pinning, widths, grouping, density and page size), how the table is set up and never what the reader is in the middle of: the selection, the page and the open details stay out. The table applies a view by filling in what it leaves out from the setup it opened with, so switching views never carries one view's filters into the next, and runs reconcileDataTableView (exported) on it first: columns that no longer exist drop out of every part, a filter whose condition no longer fits its column's type is dropped, new columns join the end of the order and widths clamp to each column's limits. onViewChange hears the setup once per change, for a host keeping the reader's last view. Scale: an unpaged, ungrouped table of more than 200 rows (virtualize, auto by default, or true and false) draws only the rows in view and a few either side (TanStack Virtual, in a small VirtualRows part, so the React Compiler's opt-out stays with the rows), between two spacer rows that hold the scroll height, each row measured as it lands, so rows of any height scroll true. It scrolls inside its frame (maxHeight, 640px when unset) with its header held, sends its first screen of rows in the server's HTML, states aria-rowcount on the table and aria-rowindex on each drawn row, so a reader hears "row 340 of 10,000", and scrolls a row into view before the keyboard focuses it. Row grips and the stacked and grouped layouts draw every row. hasMore ends the rows with a loading row (a neutral Spinner and loadLabels.loadingMore), which calls onLoadMore as it comes into view, once per batch. The engine's sort, filter and selection state is rebuilt only when what it says changes, and text compares through one shared collator, so a re-render never re-sorts thousands of rows. The search stays with the typist at any size: the field takes each key at once and the rows follow when the thread is free, under the same thin line as a refetch but without the dimming, which would flicker on a table that filters at once; the table is aria-busy until they catch up, and the result count is announced once they have. It reads each row's text once per rows array, so a host passes a new array when its rows change, and walks the rows for a column's filter kind and choices only when the rows or that column's reading change, so an inline columns array costs nothing. Server mode and loading: manual hands sorting, filtering, search and paging to the host: the table stops doing them and reports each change through its pairs, and shows the rows it is given, already sorted, filtered and paged, with rowCount (every match on the server) driving the pager, the count and Select all N. Since the table holds only a page, a TableToolbar's Select all N then selects every match as a query (TableToolbarSelection.allMatching, with the rows unticked since as except), until the selection clears or the search or filters change. loading on a first load draws skeleton rows under the real header, each line in a box one text line tall so the rows that replace them do not jump, the count left out; over rows already shown the rows stay, dimmed and inert, under a thin line on --color-status-info-border running across the frame's top edge, so the table never blanks; the table is aria-busy throughout. error with nothing on screen replaces the body with the bordered EmptyState in its error form (role="alert", Try again calling onRetry); over rows already shown it is an error Banner above the table, at the frame's --radius-300 corner rather than the page strip's square one, with the same retry, and the rows stay. Every word is a loadLabels entry (DataTableLoadLabels, the loading row's words included). Column types: a column's type sets what the column leaves unsaid, its cell, alignment, sort, filter conditions and the editor editable opens, and every type composes a part the library already draws: text; number and currency (Intl, tabular figures, end-aligned); date (a YYYY-MM-DD value, medium by default, the long form on hover, or relative, which shows the absolute date until the browser knows today, so the server's HTML always matches); status (a Badge in its option's status role, sorted in the options' order, so a workflow reads in its own order); person (an Avatar and the name, or an AvatarGroup stack for a list); tags (neutral Badges, the first two shown and a caption +N for the rest); progress (a compact ProgressBar and its percent, end-aligned); checkbox (the shared check, read as Yes or No, centred, the checked first ascending, and a live Checkbox when editable); link (the URL through the app's link component, linkComponent, shown without its scheme, in body text with its underline, since the action colour's link shade misses AA on a dark card). The table's own controls whose glyph is all a reader sees of them (the row grip, the filter mark, the group and details chevrons, the toolbar's grips) take --color-text-secondary, which holds the 3:1 a control needs on every table surface in both modes. format carries the locale, the currency, the digits and the date style; the locale defaults to the table's locale (else the nearest LocaleProvider's, else en-US) rather than the browser's, for the same hydration reason, and the same locale orders text when it sorts. render still wins over the type, and an empty value draws nothing, so a sparse column stays quiet. An end-aligned column's header sits over its figures with its options button at the start, and a centred one centres its label. A checkbox column filters by is checked and is not checked, its chip always naming the condition. Sort and selection follow the controlled/uncontrolled pair convention (sort/defaultSort/onSortChange, its several-column form sorts/defaultSorts/onSortsChange, which wins when both are given and fires alongside onSortChange with the first sort, selectedIds/defaultSelectedIds/onSelectionChange) so server-driven tables stay possible.
Columns and rows. DataTable draws its own ds-table markup inside a ScrollRegion frame, so it can pin, size and stick cells Table's markup does not know about. resizableColumns puts a grip on each header's end edge (drag it, double-click to fit the content, or Alt+Left and Alt+Right on a focused header, RESIZE_STEP pixels a press); reorderableColumns lets a header be dragged to a new slot or moved with Ctrl+Shift+Left and Ctrl+Shift+Right; columnMenu adds a menu to every header (sort, pin to either edge, move, fit, reset, hide), opened from the header's options button, Alt+Down, Shift+F10 or a right-click; a column's pin holds it at the start or end edge over an opaque body, with a hairline on its inner edge and --shadow-table-pinned once content has scrolled under it; locked keeps a column where it is and visible. reorderableRows adds a grip at each row's start (drag it, or Alt+Up and Alt+Down on it), off while a sort owns the order. stickyHeader with maxHeight scrolls the body inside the frame under a header that holds. cellText sets how text fits (wrap, nowrap, truncate with the full text as the cell's tooltip, lines-2), table-wide or per column. rowActions adds an actions column stuck to the end edge, inline actions as compact tertiary CircularButtons whose tooltips open to the side, the rest in a More menu. The More button always shows, so the column says what it holds at rest; the inline actions show on hover and focus by default (rowActionsVisibility) and always on a touch screen. Widths, order, pins, hidden columns and row order each follow the controlled/uncontrolled pair. Once any of these is on, navigation defaults to grid: the table is one Tab stop with arrow-key focus across cells. Every move, pin, hide and resize is announced through the polite region, and every word is a columnLabels entry. Every drag follows the gesture rule: the dragged column, row or guide is written to the DOM per frame through the folder's dragGesture.ts, and state is committed once on release. Widths ride custom properties on the table (--ds-dt-w-*, --ds-dt-off-*, --ds-dt-total) under a fixed layout, with a filler column taking the space a narrower table leaves, so the header band always reaches the frame's edge.
Filters. filters (with defaultFilters and onFiltersChange) holds the table's filter conditions, run by the engine's column filters with rift's own test, AND across them; search (with defaultSearch and onSearchChange) and size (with defaultSize and onSizeChange) are pairs too, so a TableToolbar can own them. With columnMenu, every filterable column's menu ends in Filter…, which opens the column's filter in a Popover beside a filter mark after the header label (adding a filter when the column has none); a filtered header keeps the mark, which reopens it. The header filters and a TableToolbar's chips are the same list. A typed column's filter kind follows its type; an untyped one's is read from its values and options. A list filter has the conditions its ticks can mean: a choice column is any of (the default) or is none of, a tags column has any of (the default), has all of or has none of, so "not done" is one filter, not a list of every other status. A view may also hold is any of or is none of on a text column (VIEW_OPERATORS), which no menu offers because nobody types a list into a text field: it is how a host shows these rows, an id among the ones a reader ticked or changed, or turns a model's top-N into a filter. Its chip opens to the values it holds as the list's ticks, so each can be let go, and the operator menu keeps that operator beside the column's own. A change of filters starts from the first page and announces the new count.
The table holds still. Nothing in the table moves under the pointer. Hover, focus and selection change only tint and opacity, never a cell's size or position: the sort glyph, the header menu button, the resize line and the row actions are all in the layout at rest and fade in. A column that declares a pixel width starts at it, so a table whose columns all declare one lays out fixed from its first frame; the rest are measured once from the natural layout, a truncating column capped at TRUNCATE_START_WIDTH so its longest text cannot set its width. Layers are one fixed order in DataTable.css (body cells, pinned body cells, a hovered or focused pinned cell, the sticky header, pinned header cells, a dragged header, the guide and drop lines), so the header always paints over a row scrolled half under it, hovered or not, and a cell holding an open menu rises over all of them. Columns never overlap: a column's floor (minWidth, by default what its header needs: its padding, two letters of label, and its sort glyph and menu button) holds against every resize, a header narrower than its label truncates the label and never its controls, so a control's hover fill keeps the cell's padding from the divider, and under fixed widths a body cell clips what does not fit (menus and tooltips are fixed panels and escape the clip). The actions column's width is CSS, from the live compact control height, gap and cell padding, so a theme's density moves it too.
Editing. A column's editor makes its cells editable, with one of the library's fields (text Input, textarea Textarea, number NumberInput, select Dropdown, multiselect and combobox Combobox, tags TagInput, date DateInput, time TimePicker) or a function that renders the consumer's own; the choice kinds read the column's options, and a cell shows the chosen option's label. A cell reads as text until it is edited, the same reason status is a Badge: a page of mounted fields is heavy and noisy. checkbox and switch are the exception, live controls in the cell that save as they toggle. The editor is always the compact field, at either density, and covers its cell's own box, so a row never grows while it edits; a textarea grows over the rows below as a floating layer. An editable column's floor also fits its editor (EDITOR_MIN_WIDTH in the folder's editorKinds.ts, each compact field's narrowest width with a short value whole beside its chrome, plus the editor's inset), so a field always fits its cell, in row mode too, where the editors sit side by side. An editing cell stops clipping, so the field's focus ring shows whole, but keeps its layer: a pinned column, the actions column's Save included, still covers a scrolling cell's editor, and only a cell whose list is open rises over the table. In cell mode an editable cell shows a pencil (a compact tertiary CircularButton on a solid fill, the body under the hovered row's tint, so text never shows through it) on the side the value leaves free, the end edge of a cell that reads from the start and the start edge of an end-aligned one (figures), centred on the row, while the pointer is over it or the cell holds focus, and the pencil opens the editor. In grid mode Enter or F2 opens a cell, a printable key opens a text or number cell with that character as the new value, Enter saves and moves down, Tab saves and moves to the next editable cell, Escape cancels, a double click opens a cell, and a press outside saves it. A column's validate returns a message to keep the editor open, the field in its error state and the message floating under it as an alert. Rows stay the record: the table holds only the open draft and reports the save through onCellEdit; a save that returns a promise shows its new value quieter (--color-text-tertiary, aria-busy) until it settles, and a rejection rolls the value back, outlines the cell in --color-status-error-border with the message as its tooltip, and announces it on an assertive channel, the table's one assertive message. editMode="row" adds an Edit button to each row that opens every editable cell of the row at once, with Save and Cancel in its actions column (their glyphs in --color-status-positive-icon and --color-status-error-icon, so the two read apart by colour as well as by shape), saved together through onRowEdit; the open row takes --color-table-row-selected-bg, so it reads as the one row that is open. With no column pinned to the end, the actions column is the end edge and carries its hairline and scrolling-edge shadow. Every word is a columnLabels entry. The editors live in the folder's CellEditor.tsx, internal to DataTable until a host needs one alone.
TableToolbar
ds-table-toolbar — The control bar over a table: search, filters as chips, a Columns panel, the density switch, a bulk bar for a table with row checkboxes, the page's primary and secondary actions, a result count and an actions slot, in one role="group" row that is always one line, until a long AI command grows its composer to at most three rows, which pushes the table down. The start cluster (the AI command when there is one, then search, then the chips that fit) takes the room, and the chips that do not fit fold into a +N chip that opens the filter panel (every filter edited in place, then Add filter and Clear filters in one row); the end cluster holds the end, with Filter beside what it makes, straight after search and before its chips, and the icon controls grouped together at the end: Sort, Group, Columns and the density switch, then the secondary actions, and last the primary action (More appears only when the bar folds; below BREAKPOINT_SHEET_PX of the bar's own width Group and Columns move into it too, so every remaining icon keeps its size and gap). At most two controls in the bar carry a label; in the bar's own row those are the AI command, until it folds on a narrow bar, and the primary action, and everything else is an icon, named and tooltipped. The search field flexes between 160px and 280px around a 200px basis and yields first, so the chips take the room it would otherwise hold. The fit is measured: chips past it stay in the layout out of sight and inert, so every chip can be measured, and the bar re-fits on every resize and every filter change. In a DataTable's toolbar slot the bar reads and sets the table's own state through context and needs no props; over any other table its props carry the same state as controlled and uncontrolled pairs (filters, search, hiddenColumns, columnOrder, size) with the columns it knows about and a count. A prop always wins over the table's state. Sizing: every control in the bar, and everything the bar opens (the Filter menu, a chip's editor, the Columns panel), takes the bar's density (the table's size), all at --control-height-default or --control-height-compact, the chips held to that height since Chip's own sizes sit between the two; controls inside the table (the header menu and its filter editor, row actions, cell editors) stay compact at either density. Toggles and panel triggers are icon-only to save room, each still named and tooltipped: Filter, Sort, Group, Columns, More and the folded search are outlined neutral CircularButtons (secondary, neutral), an open trigger holding its pressed fill, and so is each secondary action (secondaryActions, an action without a string icon falling back to a neutral secondary Button). The density is the SegmentedControl switch with its segments as icons alone (showLabels={false}), each still named by its label, shown as its tooltip. The primary action (primaryAction) is the bar's one primary Button, a SplitButton when it carries a menu of further actions (the other ways to add or move rows), folding to a primary CircularButton when the bar is narrow, its menu moving into More. A trigger whose panel is open holds --color-action-passive-bg-active until it closes. The bulk bar: in a selectable table, selecting a row turns the bar into the bulk bar, laid over its own row at the same height with no fill of its own, since it is the same bar in another mode and the selected rows' tint already says what changed (the row beneath stays laid out and inert, so nothing below moves and the chips keep their fit); the count reads at the button labels' size for the bar's density. The count and Select all N never give up width; the actions take what is left, as full secondary buttons while every one fits, then each as an icon CircularButton named and tooltipped by its label (an action with no icon folds instead), and only what still does not fit folds into a More menu at the end, so nothing ever draws under the count. It reads, start to end: Clear (a CircularButton; focus then moves to the search, or the bar), the count (3 selected, All 42 selected once every matching row is), Select all N (every row the search and filters match, past the page; the header checkbox selects only the page), then bulkActions in full, each a labelled secondary Button (the destructive ones last, in the destructive tone) while there is room, and what does not fit folded into a More menu at the end, measured as the chips are; folded, every action is in the menu. The bar's two-label budget covers its own row, not the bulk bar, whose actions are the whole point of the mode. The bar is a group named by its count, and its count is live; the table's footer leaves the selection count to it. An action runs on a TableToolbarSelection, { ids, allMatching, except }, so a server table can act on every matching row as a query rather than a list of ids it never loaded; a table that holds every row resolves ids in full. Clearing the selection returns the bar's own row. On a phone screen (the viewport, since it is fixed to the screen) the bulk bar instead floats at the foot of the screen, portalled to the body so no transformed ancestor can hold it, anchored to the dynamic viewport's foot (100dvh) and sized to the visible width, inset by --padding-300 and above the safe area (plus --ds-table-toolbar-floating-offset, the host's hook for a bar of its own at the screen's foot, such as a bottom tab bar), on --color-bg-page-primary (the popover's opaque fill) with --radius-600 and --shadow-floating at --layer-sticky, and the bar's own row stays in place above the table. On a phone screen the bar's panels are sheets: Filter, Sort, Group, Columns, More and the folded search open as a bottom Drawer (the playground's own phone treatment) titled with the control's name instead of a Popover, the panels taking the sheet's width, and the filter sheet pins Add filter and Clear filters to its foot. A narrow bar on a wider screen (a table in a side panel) keeps its popovers, since a sheet rising over a desktop page for one small table would be out of scale. The count lives in the table's footer inside a DataTable, so the bar's is off there by default (showCount moves it into the bar, and the footer leaves it out); over another table it shows N results, or shown of total while anything narrows the rows. Narrower than a phone (the bar's own width under BREAKPOINT_PHONE_PX, measured, so a bar in a narrow panel folds too), or wider but too narrow for search at its floor and Filter beside the end cluster (unfolding once the bar is wider than when it folded), the bar folds to icons: More appears, holding the secondary actions and the primary action's menu under an Actions caption as menu items, then the density switch with its words shown (under a Density caption), leaving the bar (so a phone's width holds the icon controls and the primary action without overflowing), search becomes a CircularButton, search opening its field in a Popover, and the chips move into the Filter icon's panel, every filter edited in place under its column's name with Add filter last, a count riding the icon's corner: a solid pill on the count badge pair (--color-count-badge-bg and --color-count-badge-text), BottomNav's count badge, so it reads at a glance on the outlined icon buttons. Filters are one model shared with DataTable's header filters (the folder-internal filterModel.ts in DataTable): a filter is { id, column, operator, value }, conditions join with AND, and a column's kind (text, number, date, choices, a list) sets its operators. Filter opens a menu of the filterable columns, ending in Clear filters once any is set; picking one adds a filter and opens its editor; a chip (a clickable Chip, with the standard close CircularButton beside it) reads Column · value, the kind's default condition left unsaid and any other named (Budget · > · 5000), a list shown as its first value and +N, and opens the same condition editor the header does: the operator Dropdown, then the value field the kind takes (Input, NumberInput or DateInput, between taking two; a list filter, choices or tags, takes a search Input over a checkbox per choice under its condition, is any of or is none of (has any of, has all of or has none of for tags), the ticks carried across a change of condition; typing narrows the list and ticks hold across searches, so one value is a search and a tick away). The editor is a fixed 280px, so nothing in it widens the panel. Changes apply as they are made, and a filter left without a value when its editor closes is dropped. The Sort panel (shown when a column sorts) lists the sorts first sort first, each row a move grip (drag, or Alt+Up and Alt+Down, the same gesture as the Columns panel, shown once there are two), the column as a Dropdown, the direction as an icon SegmentedControl and a remove button, then Add sort (the sortable columns not yet sorted) and Clear sorts in one row; inside a DataTable without multiSort it holds one sort. The panel and the headers set one list, so a sort from either shows in both; folded, the Sort icon carries the sort count the same way, as Filter carries the filter count. Every panel the bar opens is a menu in look: the Sort, Group, Columns and More panels, a chip's editor, the +N panel, the folded search and the header filter all open on Popover's menu surface, DropdownMenu's own, and lay out to its measures: a heading per section in the menu's group-label type (--color-text-tertiary), a row per control at the menu item's inset, a separator between sections in --color-input-border-primary, and their actions (Add filter, Clear filters, Add sort, Clear sorts, Collapse all, Expand all, Show all, the folded secondary actions) as full-width menu items, an icon and a label on the menu's passive hover, never buttons floating in a panel; a compact bar takes the compact menu's measures, and the column rows carry no fill of their own. Saved views sit as compact Tabs on their own row over the bar (views/defaultViews/onViewsChange, each { id, name, view }, with activeView/defaultActiveView/onActiveViewChange): the active one is the table's starting setup, so a table opens on it: a DataTable reads the view from a bar placed directly in its toolbar slot and renders it from its first render, the server's HTML included, and a bar it cannot see into (wrapped in a component of the host's) applies it once it mounts; picking one applies its setup to the table; the active tab shows a dot before its name once the table has moved on from it, said aloud in its menu button's name ("Open pipeline options, unsaved changes") since a tab's icon is decorative; the menu (a compact CircularButton after the tabs) saves, saves as new, resets to saved, renames (in place, an Input with Save and Cancel) and deletes (never the last view); a plus saves the table as a new view, named "View N". Narrow, the tabs fold to a compact Dropdown. The bulk bar lies over the bar's own row only, so the views row stays in view while rows are selected. The Group panel (shown when a column is groupable) picks the column the rows group by, or None, then, inside a table, Collapse all and Expand all; folded, the Group icon carries a badge while grouped. The Columns panel lists every column in display order, each row a move grip and a compact ToggleSwitch, then Show all; a grip drags the row (the gesture rule: the row moves on screen per frame and the order is committed once, on release, the drop slot being how many other rows' midpoints its centre has passed) or moves it with Alt+Up and Alt+Down, announced politely; it sets the same column order the headers do. A locked column's grip is disabled, and a column the table may not hide, and the last one shown, keep their switch disabled. Every word is a labels or filterLabels entry.
The command prop adds an AI command to the bar: an AiButton at the bar's start, ahead of search, that turns the whole row into a close button and an inline Composer at the bar's density, the same takeover selecting rows makes with the bulk bar, on every width. Its text the host's handler turns into a whole view. It reaches exactly what a view holds, through the table's own applyView, and the library calls no model. An answer lands in order: the button holds its working state while the body draws skeleton rows for MOTION_TABLE_COMMAND_SKELETON_MS, then the new rows arrive with the toast. On a narrow bar the button folds to its icon. The takeover is a sequence on the motion tokens: the bar's controls fade as the AI button collapses to the close button's circle, the close button arrives, then the composer opens out from beside it, and closing fades the row out and the bar back; the bulk bar fades in on the same beat. Sending, the close button or Escape brings the bar back with focus on the button, which carries the working state, and the outcome lands in a toast through the Toast stack: the surrounding ToastProvider's if there is one, else the bar's own at the bottom-right. A changed view takes the positive role and an Undo and leaves after MOTION_ACTION_DISMISS_MS, its timer pausing on hover or focus; a failure takes the error role and stays until dismissed. A message with no change takes the info role and leaves on the usual timer; no change and no message shows nothing. After a failure the text returns to the composer. A bulk bar in the same row outranks an open command, which returns when the selection clears. Shows only inside a DataTable.
Dialog
ds-dialog — General-purpose modal for arbitrary content; for confirm/cancel prompts use AlertDialog. Controlled by design, like every standalone modal (see the Components intro): the host owns open and hears dismissals through onOpenChange. Panel: --radius-300, --color-bg-page-primary, hairline --color-bg-container-border border, --shadow-modal, over a --color-scrim backdrop; opens with the standard base-duration scale + fade (--motion-duration-base / --motion-ease-standard). Header: --font-heading-3-* title with optional --font-paragraph-sm-* tertiary description and the shared close, a compact tertiary CircularButton (32px, tooltip={false}). Body slot scrolls (overflow-y: auto) when content exceeds the viewport-capped panel height; optional footer slot right-aligns consumer-provided Buttons. Sizes: sm 400px / md 560px (default) / lg 720px max-width. Behaviour: portal to <body>, role="dialog" aria-modal="true", and the shared overlay contract (see the Components intro): stacked Escape, focus trap and guarded restore, inert background, counted scroll lock. dismissible={false} disables ESC, backdrop click, and hides the close button — the layer stack swallows Escape rather than passing it to an overlay beneath. The close button is named by closeLabel.
AlertDialog
ds-alert-dialog — Modal confirmation for important or destructive actions. Controlled by design (see the Components intro): the host owns open; Cancel, Escape and the backdrop call onCancel and then onOpenChange(false), and Confirm calls onConfirm and then onOpenChange(false). A portal-rendered overlay: --color-scrim backdrop behind a --radius-300 panel on --color-bg-page-primary with --border-025 border and --shadow-modal, title + description text, and a Cancel/Confirm Button pair. tone="destructive" (the deprecated variant="destructive" still works) swaps the icon to a warning, colours it and the title with the error tokens, and renders the confirm action as the outlined destructive Button (variant="secondary" tone="destructive"). Follows the shared overlay contract (see the Components intro) — stacked Escape cancels, focus trap, guarded restore, inert background, counted scroll lock — with initial focus landing on the Cancel button, so the safe action is one keypress from the destructive one; the panel wires aria-labelledby/aria-describedby to the generated title/description ids. SSR-safe (portal only mounts client-side). For general-purpose modal content use Dialog; AlertDialog is only for confirm/cancel decisions.
Drawer
ds-drawer — Edge-anchored modal panel. Controlled by design, like Dialog: the host owns open. Shares Dialog's modal contract (portal to <body>, role="dialog" aria-modal="true", the shared overlay contract from the Components intro, dismissible={false} to disable ESC/scrim/close) but slides in from a viewport edge instead of scaling from centre. side picks the edge (left/right/top/bottom, which are the screen's, or start/end, which follow the reading direction); the panel rests off-screen via a translate transform and returns to zero when open, over a --color-scrim backdrop that cross-fades. Surface: --color-bg-page-primary with --shadow-modal and a hairline --color-bg-container-border on the edge facing the page. Header is a --font-heading-3-* title with optional --font-paragraph-sm-* tertiary description and the shared close, a compact tertiary CircularButton (32px, tooltip={false}); the body scrolls independently so header and footer stay pinned; the footer right-aligns consumer Buttons. Sizes measure along the slide axis — sm/md/lg are 320/420/560px for side drawers and 30/50/75vh for top and bottom. Side drawers go full-width under 480px. Use Dialog for centred, self-contained prompts; Drawer for filter panels, detail views, and mobile navigation. The close button is named by closeLabel. The drawer stays mounted so it can slide shut, so its presence says nothing about its state: the root and the panel carry data-state="open" or "closed" for a consumer's styles and tests.
A heading in place of the title. heading puts content (a site's logo, say) where the title text would show, and the title moves into a visually hidden <h2> that still names the dialog, so the dialog keeps its accessible name whatever is drawn.
CommandPalette
ds-command-palette — Modal Cmd+K launcher over a grouped command list. Controlled by design (see the Components intro): the host owns open, and both dismissal and the hotkey report through onOpenChange. Panel is a 560px glass card — --color-bg-glass behind the documented 24px backdrop blur, at the floating-surface --radius-600 with a --border-025 --color-bg-container-border hairline and --shadow-floating, the same recipe as the site chat panel and AppSidebar's floating variant — pinned 10vh from the top over a --color-scrim backdrop, capped at 60vh. A search row (24px search icon, borderless input, the shared close, a compact tertiary CircularButton) sits above a scrolling list of --radius-200 command rows; the input takes focus on open (the closed state's visibility flips instantly on open and only delays on close, so the focus always lands), and while it holds focus the row's bottom border takes --color-input-border-selected, the same active signal a focused Input gives its own edge; each row takes an optional 20px icon, a --font-paragraph-emphasis-* label, an optional tertiary description line, and a shortcut array rendered as compact Kbd keycaps (see Kbd); a trailing node renders at the row's right edge after any keycaps — a badge or affordance the keycap slot can't express, kept non-interactive because it sits inside the option row (the site palette's ask-the-chat row carries the AI-ring chip this way). Group headings use --font-paragraph-sm-* tertiary; the active row takes --color-action-passive-bg-hover. Filtering matches label, description, and keywords; disabled commands stay visible but are skipped by the highlight. A destructive command takes --color-action-destructive-text-secondary on its label and icon and the --color-status-error-bg tint when highlighted, matching a destructive DropdownMenu item. Keyboard: arrows wrap through the flattened list, Home/End jump to the ends, Enter runs, Escape closes, and hotkey binds Cmd/Ctrl+K globally through the shared shortcut listener, from a text field and over an open dialog (set false when the host app owns the shortcut); hotkeyKeys names other shortcuts for it. A command's keys draws its shortcut hint for the reader's platform, where shortcut draws literal keycaps. Shares Dialog's modal contract (the shared overlay contract from the Components intro): stacked Escape working dialog-wide, focus trap, guarded restore, inert background. A footer hint row documents those keys and hides under 480px. Closed state uses visibility: hidden so the input never enters the tab order. The list scrolls with a quiet scrollbar (scrollbar-width: thin, --color-divider thumb, stable gutter, so filtering toggling overflow cannot reflow the rows). The page behind is locked by cancelling scroll input — wheel and touch pass only inside the list (overscroll-behavior: contain stops end-of-list chaining) — never by hiding overflow, so the document scrollbar stays rendered and the page never shifts or blinks on toggle. The loading status is loadingMessage and the footer hint words are hintLabels; while loading or empty, the list drops its listbox role and the message is a status region, and the input points at no command. hintLabels also carries escapeKey, the visible name on the Escape key cap ("esc").
CommandPaletteTrigger
ds-command-palette-trigger — The entry point to a command palette: a <button> dressed as a search field, a leading icon at --icon-size-500, the prompt in --font-paragraph-sm-* --color-text-secondary and the shortcut drawn as compact Kbd keycaps pushed to the far end, because typing happens in the palette itself. At the compact size the keycaps step down to --font-caption-* with no block padding, so they sit inside the shorter row rather than filling it. It is the row height (--control-height-*) on the track recipe SegmentedControl's strip wears, so it reads as one family beside the theme toggle and the theme picker, and it hovers like a field (the border steps to --color-input-border-hover). The host sets its width. iconOnly folds it to a circle the row height across, hovering like the theme picker's trigger, for a row with no room for the field, and its glyph steps from the prompt's --color-text-secondary to --color-action-icon-default, since with no prompt beside it the glyph is the control and the circles beside it wear that colour; the prompt (label) is always the accessible name, and the keys drawn come from keys (default mod+k, ⌘ K on a Mac and Ctrl K elsewhere, announced as both since the palette answers to either), or from literal shortcut keycaps, which win when given, and aria-keyshortcuts follows them (⌘ read as Meta). It only opens: its onClick sets CommandPalette's open state, and the palette owns the shortcut. Presentational, with no 'use client'.
Lightbox
ds-lightbox — The fullscreen media viewer: an image or custom media centred over a deep frosted scrim, with a caption chip, a close control, and optional previous/next stepping for galleries. Controlled by design (see the Components intro): the host owns open and the gallery index. Rides the shared overlay behavior layer like the other modals — Escape through the layer stack, focus trapped and restored with the page inert, the counted scroll lock — while gallery stepping (chevrons plus the arrow keys, armed by onPrev/onNext; the consumer owns the collection and index) goes through the shared shortcut listener, scoped to the panel and armed while open, so the arrows step images only while the lightbox is the surface in front. The scrim is --color-scrim laid twice (≈0.75 light / ≈0.91 dark — a media viewer wants a deeper floor than the dialogs', without a second scrim role) over a 12px backdrop frost. Media caps at the viewport minus --gap-1000 margins at --radius-300 on --shadow-modal, and springs from 0.96 scale on --motion-ease-entrance. The furniture — caption chip and the three --icon-size-1200 circular controls — lives in scrim-world rather than page-world: a white-alpha wash with white glyphs, sanctioned in place with ds-allow(color), because the scrim floor is dark in both themes and any theme token would dissolve into it in dark mode; the controls also sit at z-index: 1 so a full-width image on a phone cannot swallow the leading chevron. The panel is a full-viewport stage transparent to clicks (pointer-events: none, children auto), so clicking anywhere off the media reaches the backdrop and dismisses. The viewer, close and stepping controls are named by viewerLabel, closeLabel, previousLabel and nextLabel; a consumer's own aria-label on the panel wins.
Popover
ds-popover — Anchored contextual overlay. Wraps a trigger and positions a --radius-200 panel (min-width 200px, --padding-400, --color-bg-page-primary like the other floating menus and panels — Dropdown, DropdownMenu, ContextMenu, ModelPicker — --border-025 border, --shadow-floating) on the chosen side (top/bottom/left/right). Trigger mode is click (outside-press and Escape dismiss, through the layer stack, so neither also closes a Dialog it sits in) or hover; open state follows the controlled/uncontrolled pair convention (open/defaultOpen/onOpenChange). The ref and native attributes land on the root wrapper, which holds the trigger and the panel, and the consumer's own mouse handlers compose with the popover's. Content is arbitrary ReactNode — unlike Tooltip, which is text-only. Sizes: default, compact. surface="menu" draws the panel on DropdownMenu's own surface instead (its --padding-100 inset, --color-input-border-primary border and --radius-300 corner), for a popover that sits among menus and should read as one, a table toolbar's panels being the case; its rows, headings and separators are the content's, drawn to the menu's measures.
align picks the trigger edge the panel lines up with on the top and bottom sides, start by default.
HoverCard
ds-hover-card — Rich preview panel on hover or focus: a profile behind a username, a component summary behind a link. Sits between Tooltip and Popover — it opens like Tooltip (hover or focus with the shared MOTION_HOVER_SHOW_DELAY_MS/MOTION_HOVER_HIDE_DELAY_MS timings, Escape dismisses) and dresses like Popover's floating-panel family (--radius-200 panel on --color-bg-page-primary, --border-025 hairline, --shadow-floating, --padding-400, arbitrary ReactNode content, min-width 240px / max-width 320px). Unlike Tooltip the panel is interactive: it stays open while the pointer or focus is inside it, so links and buttons in the preview are reachable. Hidden state is display: none (transitioned discretely, so the fades survive), keeping the closed panel out of the accessibility tree, the tab order, and the page's scrollable overflow — Tooltip and Popover hide the same way, so an edge-adjacent trigger's invisible panel can never add horizontal scroll; the trigger is cloned with aria-expanded/aria-controls. Positions: top/bottom/left/right.
DropdownMenu
ds-dropdown-menu — Action menu opened from a trigger element (contrast with Dropdown, which is a form select). The panel is --radius-300 on --color-bg-page-primary with a --border-025 --color-input-border-primary hairline and --shadow-floating, --padding-100 inset, aligned start or end. Entries are a typed tree: items (label, an optional description line under it in --font-caption-* and --color-text-tertiary, the icon centred on the pair, optional Material Symbol icon, a keyboard hint as literal shortcut text or as keys in the shortcut grammar, written for the reader's platform and announced through aria-keyshortcuts, disabled, destructive — the --color-action-destructive-text-secondary label and icon, over the --color-status-error-bg tint when hovered or focused), separators, labelled groups, and nested sub-menus via children. Full keyboard navigation across the flattened item list; hover uses --color-action-passive-bg-hover. The menu semantics are cloned onto a consumer-supplied trigger element, and the consumer's own onClick/onKeyDown compose with the menu's — theirs fire first, never overwritten. Open state follows the controlled/uncontrolled pair convention (open/defaultOpen/onOpenChange); the ref and native attributes land on the root wrapper. Sizes: default, compact. Items are --radius-200 rows, the inner step of the panel. An item given selected is a member of a choice set (role="menuitemradio" with aria-checked): the chosen one holds the passive wash (--color-action-passive-bg-hover, stepping to --color-action-passive-bg-active on hover), with text and icon at full strength and the shared ChosenMark trailing (the one place the action colour appears in a menu, as the selected-option check the colour roles sanction; the row fill itself never takes it). Sub-menu panels and ContextMenu, which shares the entry model, render the same state. An item given checked is one of a set picked independently (role="menuitemcheckbox"): the shared CheckboxIndicator leads the row, ticked when true, and the host toggles it in onClick. Choosing a checkbox item leaves the menu open, so several can be toggled in one visit; closeOnSelect overrides the default either way (true for every other item). The panel and its sub-menus are placed by the shared anchored-position behaviour, align lining the panel up with the trigger's start or end edge and sub-menus opening to the right, flipping left when there is no room.
ContextMenu
ds-context-menu — Right-click (and keyboard ContextMenu / Shift+F10) menu anchored at the pointer position. Wraps its children as the right-clickable area; the panel is position: fixed at the event coordinates, clamped inside the viewport, and closes on outside click, scroll, resize, Escape, or item activation. Entries reuse DropdownMenu's typed tree (DropdownMenuEntry: items with icon/shortcut or keys/disabled/destructive/selected (the DropdownMenu spec owns the selected recipe), separators, labelled groups, one level of children sub-menus) and the exact same panel recipe — --radius-300 on --color-bg-page-primary, --border-025 --color-input-border-primary hairline, --shadow-floating, --padding-100 inset, --motion-duration-fast appear. Keyboard: the panel takes focus on open (returning it on close) and tracks the highlighted item with aria-activedescendant; arrows move, Home/End jump to the ends, Enter activates, Escape closes. Sizes: default, compact. Use DropdownMenu when the menu opens from a visible trigger; ContextMenu when it opens on the content itself.
Tooltip
ds-tooltip — Text-only contextual label on hover or focus. The bubble rides the floating surface, the same recipe as Popover: --color-bg-page-primary with --color-text-primary, a --border-025 hairline in --color-bg-container-border and --shadow-floating, at --radius-100, --font-paragraph-sm-* — same-polarity in both themes (light bubble on light, dark on dark; the old inverse fill read too stark). The rotated-square arrow shares the fill and draws the hairline on its two outward edges. The panel is placed by the shared anchored-position behaviour (see the Components intro), centred on the trigger, so a scroll container around it (a bordered Table's wrapper, a rail) cannot clip it, and it flips to the opposite side when its own has no room; while it shows it paints in the top layer, above everything on the page (Elevation & Depth → Stacking); the arrow slides along the bubble to stay on the trigger's centre when the clamp moves the bubble off it. Positions top/bottom/left/right, plus start/end, which follow the reading direction, with a 4px slide-in transition; showDelay/hideDelay control timing (300/150ms default). Escape dismisses a visible tooltip from anywhere on the page (WCAG 1.4.13), and only the tooltip: it is a layer on the dismissal stack, so inside a Dialog the first Escape clears it and the second closes the Dialog. The panel has role="tooltip" and an id; Tooltip clones its child element with aria-describedby pointing at that id — host elements get it automatically, and Button/CircularButton accept the attribute natively. Content is a string; anything richer belongs in Popover.
Divider
ds-divider — Thin rule separating stacked content: --border-025 (1px) in --color-divider. Plain horizontal renders a semantic <hr>; a label variant sets text inline in the line (--font-paragraph-sm-* in --color-text-secondary, center or start position, role="separator"); vertical stretches to container height inside flex rows (aria-orientation="vertical"). Spacing prop maps to the gap scale: none/sm (8px)/md (16px, default)/lg (20px) — block margin when horizontal, inline when vertical. Not for separating page sections under h2 headings — the h2 bottom border already does that (see Section Dividers above); Divider is for forms, lists, toolbars, and card interiors.
Pagination
ds-pagination — Page navigation for long datasets; pairs with Table. A <nav> of pill page buttons (40px, --radius-pill) with chevron arrows at each end; first and last pages always visible, ellipses cover the gaps (siblingCount controls the window, default 1). Current page takes the SegmentedControl active treatment: --color-action-primary-bg fill with --color-action-primary-text, aria-current="page". Idle buttons: --font-paragraph-emphasis-* in --color-text-secondary, hover --color-action-passive-bg-hover. Arrows disable at the ends (opacity: 0.4, cursor: not-allowed). size="compact" swaps the numbers for a "Page X of Y" readout (--font-paragraph-sm-*) between 32px arrows. The page follows the controlled/uncontrolled pair convention (page/defaultPage/onPageChange), and the readout and the page and arrow names come from readoutLabel, pageLabel, previousLabel and nextLabel, each with an English default.
Stepper
ds-stepper — Step-by-step progress indicator for wizards and multi-stage flows. An <ol> of steps with CSS-drawn connectors, labelled "Progress" by default (a passed aria-label names it for its flow); the active item carries aria-current="step". Indicators are 32px --radius-pill circles: complete shows a check Material Symbol at --icon-size-500 in --color-action-primary-text-tertiary inside a --color-action-primary-border-secondary outline, active takes the --color-action-primary-bg fill with --color-action-primary-text, upcoming shows its number in --color-text-tertiary on a --color-bg-container-border outline. Labels use --font-paragraph-sm-emphasis-* (active --color-text-primary, complete --color-text-secondary, upcoming --color-text-tertiary); optional descriptions are --font-paragraph-sm-* in --color-text-tertiary. Connectors are --border-050 lines in --color-divider, turning --color-action-primary-border-secondary behind completed steps. With onStepClick, completed and active steps become buttons (indicator hover --color-action-passive-bg-hover, focus ring --color-action-primary-bg); upcoming steps stay non-interactive. orientation="vertical" stacks the list with connectors dropping below each indicator. Colour changes transition with --motion-duration-base and --motion-ease-standard. The check is decorative, so a completed step also reads completeLabel (default "Completed") as visually hidden text after its label.
Stat
ds-stat — A single headline metric: display-weight numeral over a quiet label, with an optional trend delta. Value uses --font-sub-display-* (30px/300) by default, --font-display-2-* (64px/300) at large, so numerals carry the display tier's weight and follow the theme (300 in the base theme). Label: --font-paragraph-sm-* in --color-text-tertiary. Delta: --font-paragraph-sm-emphasis-* with an --icon-size-500 (20px) Material arrow; colours by trend through the theme-split trend tokens — up → --color-trend-up, down → --color-trend-down, neutral → --color-text-tertiary (the earlier always-accent colouring was unreadable on light surfaces; see the Trend token section). deltaPlacement="inline" (ds-stat--delta-inline) moves the delta to the right of the value, bottom-aligned to its baseline via a grid re-template, for dashboard KPI tiles; the default stacks it below the label. deltaPlacement="auto" (ds-stat--delta-auto) is inline while the stat has room and stacked once it is narrow: the root fills its parent and is a size container (ds-stat), and an inner ds-stat__body re-templates below 180px of its own width (about a half-width tile on a phone; a four-across desktop tile stays inline), so a KPI tile restacks in a two-up phone grid or beside an open panel whatever the viewport. Compose several in a flex row for a metrics band.
AnimatedNumber
ds-animated-number — A number that counts to its value: from zero on mount, and from the previous value when value changes, easing out (cubic) so the landing reads as arrival rather than a stop. It owns no face, size, or colour — the component is a number inside someone else's composition (a Stat, a table cell, a sentence) and inherits the surrounding type entirely; its one typographic opinion is font-variant-numeric: tabular-nums, so the layout holds still while the digits roll. The tween budget is MOTION_COUNT_UP_MS from tokens/motion.ts (overridable per instance via duration) — an animation pace, not a schedule timing, so the component checks reduced motion itself in JS and snaps straight to the value. Formatting is decimals, grouped in the resolved locale (the locale prop, else the nearest LocaleProvider's, else en-US), or a custom format function called per frame. Screen readers hear only the settled value: the rolling digits are aria-hidden behind a visually hidden span carrying the target.
CodeBlock
ds-code-block — Monospace code in a --color-bg-container-primary container with --radius-300 and a hairline border. The original sanctioned monospace context in the system, set in --font-family-code (the theme's primary family everywhere outside code contexts). Optional header row: filename (mono, --color-text-secondary), uppercase language tag (--color-text-tertiary, 0.08em tracking), and a copy button that confirms with a check for 2s. Code text is 14px/20px, --color-text-primary; long lines scroll horizontally. An optional maxHeight prop caps the block: the code area scrolls vertically inside while the header stays pinned. An optional collapsible prop adds a chevron beside the filename (--color-icon-primary, 20px, rotates −90° when closed) that collapses the code area with the same 0fr/1fr grid animation as Accordion; defaultCollapsed starts it closed. No syntax highlighting — monochrome by design, no dependencies.
CodeDiff
ds-codediff — Unified diff view for code changes, in the chat cards' shell (a --color-chat-card-bg container with the code family's --radius-300, because its rows run to its edges, and a hairline border, its file row at the family's one-row height on the --padding-500 inset, the filename in the family's one-row label style, code set in --font-family-code at 14px/20px). The exported parseUnifiedDiff turns the diff string into typed lines: + additions, - removals, @@ hunk headers, everything else context. File header lines (+++, ---) and \ No newline markers are skipped, and a diff with no hunk headers is treated as one hunk numbered from line 1. Added rows tint --color-status-positive-bg and removed rows --color-status-error-bg, with the +/- marker in the matching status text colour; hunk rows sit on --color-bg-container-secondary in --color-text-tertiary. Two gutters track old and new line numbers (--color-text-tertiary, excluded from text selection so a copied diff carries only code); showLineNumbers toggles them, and an optional filename header adds a "+N -N" summary in the positive and error text tokens. The marker and gutter columns are aria-hidden and the change type is announced through visually hidden text; long lines scroll horizontally inside a keyboard-reachable labelled region. Purely presentational, no 'use client', no syntax highlighting. Every announced string is a prop with its English default: diffLabel(filename) names the scroll region ("Code diff: <filename>"), changeLabels carries the hidden prefix before each changed line (added "Added line:", removed "Removed line:"), and statsLabel(additions, deletions) is the header summary a screen reader hears ("3 added, 1 removed") while the visible counts stay hidden from assistive technology. A --- line is read as a file header only when a +++ line follows it directly outside a hunk's counted body, so a removed -- comment or an added ++i stays a content line.
Kbd
ds-kbd — A single keyboard key rendered as a keycap, for shortcut hints in menus and docs prose. A semantic <kbd> on a --color-bg-container-primary chip with a --border-025 --color-bg-container-border border at --radius-100; legend in --font-paragraph-sm-* at em weight, --color-text-tertiary, centred with a min-width so single letters stay square-ish (24px default, 20px compact). Sizes: default, compact (the compact size matches CommandPalette's shortcut hints, which render through Kbd). Compose several for a chord (⌘ + K), or pass keys (the shortcut grammar, mod+k) to draw a whole shortcut in the reader's platform legends as nested <kbd> keycaps, one ds-kbd__step group per step of a sequence, the outer ds-kbd--combo shedding the keycap chrome; keyLabels replaces a legend. Reading the platform makes it a client component ('use client'), still renderable from a Server Component as a client boundary.
Quote
ds-quote — Blockquote with optional attribution, two registers. default: body-size text in --color-text-secondary behind a --border-050 left rule in --color-bg-container-tertiary. pull: --font-sub-display-* (30px/300) in --color-text-display, no rule — the scale is the emphasis. Attribution: em-dash + --font-paragraph-sm-emphasis-* primary; detail line: --font-paragraph-sm-* tertiary. Renders semantic <figure>/<blockquote>/<figcaption>.
Field
ds-field — The scaffolding shared by every labelled form control, so the wiring exists once rather than per component. Renders the <label> (--font-paragraph-emphasis-*, --color-text-primary) with its htmlFor, the required marker (ds-field__required, --color-core-accent-coral, aria-hidden — the control's real required attribute is what gets announced), the control itself as children, the helper/error <p> (ds-field__helper, --font-paragraph-sm-*, --color-text-tertiary), and an optional aside slot (character counters, units) in a ds-field__footer wrapper. Modifiers: --error recolours the helper to --color-status-error-border; --disabled dims the label to --color-input-text-disabled; --compact drops the label to --font-paragraph-sm-*. errorText is the newer error form: the message renders as its own ds-field__error paragraph under the control in --font-paragraph-sm-* --color-status-error-text, with aria-live="polite", while the helper text keeps its colour and place beside it; the field reports itself invalid, and describedBy names the error then the helper. The older form, error with the message in helperText, is unchanged.
Field deliberately owns no layout — the flex column and gap stay on the consuming component's own root class, so adopting it changes no spacing. It generates the control id when one isn't supplied, derives the helper id from it, and exposes { controlId, labelId, describedBy, invalid, required, disabled } through useField(), which returns null outside a Field so a control still renders standalone. A group mode renders the label as a <span id> instead of a <label htmlFor> — a set of controls has no single labelable element, so the group node names itself with aria-labelledby pointing at it; CheckboxGroup, RadioGroup and SignaturePad compose it that way. Input, Textarea, DateInput, Dropdown, RichDropdown, Combobox, FileInput, ColorPicker, NumberInput, PinInput, TagInput, TimePicker and Slider compose inside it directly; the inline choice controls (Checkbox, RadioButton, ToggleSwitch) keep their beside-the-control labels but carry the same helperText/error vocabulary themselves, and Rating, SegmentedControl, ToggleGroup, DatePicker and DateRangePicker stay outside.
Figure
ds-figure — Image + caption in a --radius-300 container with --color-bg-container-secondary fill. Accepts any image element as children (plain <img> or next/image); the media slot stretches it full-width. Caption: --font-paragraph-sm-* tertiary, --padding-400/--padding-500, hairline top border. Passing onClick makes it zoomable: the media wraps in a bare full-width <button> (ds-figure__zoom — ARIA forbids role="button" on <figure> itself, and a real button brings Enter/Space activation for free) with cursor: zoom-in, hover dimming the image to 0.88, and the action-colour focus ring drawn inset so the figure's overflow clip can't crop it.
Avatar
ds-avatar — User identity circle (--radius-pill) in three sizes — sm 32px, md 40px, lg 56px. Renders the image when src is provided, falls back to initials derived from name, then to a person icon. Initials sit on one of six fills (ds-avatar--fill-N, the --color-avatar-bg-* tokens, each with its --color-avatar-text-* initials colour), picked from a hash of the name, so one person is one colour everywhere; fill sets one by number, or neutral keeps --color-bg-container-secondary. The prop is fill rather than PixelAvatar's ink on purpose: a pixel ink is one colour a sprite is drawn in, while an avatar fill is a pair, a fill and the initials colour on it, on tokens of its own. The person icon stays grey, since it stands for no one in particular. An optional status dot (online/away/offline/busy) sits on the rim, filled with the matching status border token and ringed in --color-bg-page-primary. role="img" with a label from alt/name. Its size is a ladder of mark sizes rather than the default/compact density pair, a departure recorded with its reason in SANCTIONED_SIZES in scripts/validate-prop-vocabulary.mjs, which is the list of every component that keeps a scale of its own.
PixelAvatar
ds-pixel-avatar — A generated character mark: the name is the seed, and a small pixel creature and one of six inks are drawn from it, so the same name is always the same character and nothing is designed, uploaded or stored. For things that have a name before they have a logo — projects, workspaces, accounts, agents. The sprite is an 8x8 grid mirrored left to right (so it reads as a face, not noise) inside a one-cell margin, which puts its ink in the same box a Material icon's sits in; each row has a job (antennae, crown, head, eyes, cheeks, jaw, legs, feet) and a fill chance to match, with the centre columns near certain through the head so every character has a solid core. The hash is FNV-1a feeding a mulberry32 generator: pure and deterministic, no hooks and no browser API, so it renders from a Server Component and the server and client draw the same sprite. Size rides the icon scale (sm/md/lg/xl = --icon-size-500/600/800/1200), set as --icon-size on the root, so it seats wherever an icon does. Colour is one of the --color-pixel-ink-1 → -6 tokens (ds-pixel-avatar--ink-N), mode-split so the character holds its contrast in both themes. variant="tile" (ds-pixel-avatar--tile) seats the sprite on a --radius-200 wash of its own ink, mixed to transparent, for a larger standalone mark. Decorative by default (aria-hidden), since it usually sits beside the name it was drawn from; passing label switches the root to role="img" with that name. A name picks one of six inks, so two names in a list can land on the same one. ink overrides the pick, and the exported distinctPixelInks(names) returns an ink per name for a list shown together: each name keeps its own ink when that is still among the least used and sits at least two steps from the row before it on the hue ring the inks are numbered round, and otherwise takes the next that does, so the first six all differ, a longer list repeats evenly, and a row never follows its own colour or the hue beside it (a muted theme draws neighbouring hues close together). AppSidebar offers the avatar as an item option (pixelAvatar) and ThreadPanel draws it as the default project mark; both spread their inks this way.
AvatarGroup
ds-avatar-group — An overlapping stack of Avatars with a "+N" counter for the overflow — the "who's here" affordance. Purely presentational (no 'use client'): it wraps each child in a ring-bearing item (--radius-pill, a --border-050 --color-bg-page-primary box-shadow ring so overlaps stay legible on any surface) and overlaps them by --gap-200. It spreads the fills over the stack with distinctPixelInks, so overlapping neighbours never share a colour; an avatar given its own fill keeps it. The group's size (sm/md/lg, Avatar's own 32/40/56px ladder) is cloned onto the children so a stack can never render mixed sizes, and sizes the counter circle to match — --color-bg-container-secondary fill, --color-text-secondary text at the same initials type ramp as Avatar. max (default 5) caps the visible avatars; the counter is role="img" with an accessible "N more" label, overridable via overflowLabel. The root is role="group" — pass aria-label to name the collection. overflowLabel also takes a function of the hidden count.
ContactCard
ds-contact-card — Full-width contact row card: icon-or-logo slot, label (--font-title-body-*) with a truncating value line (--font-paragraph-sm-*, --color-text-tertiary), and a trailing affordance icon. --radius-300 on --color-bg-container-primary-semi with a --color-bg-container-border hairline; hover deepens to --color-bg-container-secondary. Renders as an <a> (protocol links like mailto: never open a new tab even when external) or, in copyOnClick mode, as a <button> with no navigation; copyable instead adds a discrete copy button alongside. Neither writes to the clipboard: both call onCopy(value), and the host does the copying there. The trailing icon signals the behaviour: arrow_forward, open_in_new, or content_copy.
LinkList
ds-link-list — Vertical list of links at --gap-500. Each row: a 28px logo image or Material Symbol, then a --font-title-body-* title with an inline indicator and optional --font-paragraph-sm-* subtitle lines in --color-text-tertiary. Rows default to <a target="_blank"> with an open_in_new indicator; an item with newTab: false stays in the current tab and shows arrow_forward instead, for links inside the same site. Hover is an opacity dim. Data-driven via an items array — lighter-weight than a stack of ContactCards; used for publication and award lists and in-site link rails.
SectionTitle
ds-section-title — The standard section heading used across the docs site: an <h2> in --font-heading-2-* --color-text-secondary with an optional trailing slot (count, badge, metadata) in --font-paragraph-* --color-text-tertiary, closed by a --color-divider bottom border with --padding-500 breathing room. This is the h2-carries-the-divider rule from the typography spec, packaged as a component; the component owns only the heading-to-rule half of the header-divider rhythm (see Section Dividers) — the --gap-1000 below the rule is the consuming page's job. divider={false} (ds-section-title--no-divider) drops the border and reduces the clearance to --padding-400, for headings above content that draws its own lines (bordered tables, calendars, the /overview map sections' diagram canvases) — where a second rule would double up, the section separates by whitespace alone. A page may keep the divider on its column-top headings while the sub-sections below drop theirs (the /overview arrangement): the top of a column earns the anchoring line, the line-heavy sections underneath do not.
Instructions
ds-instructions — Step-by-step guidance list. Each step has a --radius-pill indicator badge, joined to the next by a hairline line (--color-bg-container-border). The badge shows the step's icon when it has one (an icon always wins), otherwise the step number, or with numbered={false} a small --color-icon-primary dot. Beside it sits a --font-paragraph-emphasis-* label and optional --font-paragraph-* description. Renders as an <ol>; directions vertical (default) and horizontal; sizes default/compact.
Timeline
ds-timeline — Ordered sequence (<ol>) with connected markers; vertical (default) for histories and process narratives, horizontal for compact steppers. Markers: 12px dot in --color-bg-container-tertiary; numbered upgrades to a 28px badge (--color-bg-container-primary fill, hairline border); a per-item icon renders as a bare 24px Material Symbol (no circle) centred in the same 28px box. Connector: --border-050 line in --color-divider, hidden after the last item; it stands off below/after bare icon markers instead of passing behind them. Item anatomy: meta (--font-paragraph-sm-* tertiary) over title (--font-heading-3-*, 22px/600) over description (--font-paragraph-* primary, offset --gap-200 below the title).
ds-timeline--company — a résumé/pipeline variant (variant="company", always vertical). The marker is a 32px logo image (ds-timeline__marker--logo, bare transparent box) instead of a dot/icon, and each entry carries a company/tool name (ds-timeline__company-name, --font-title-body-*, 16px/600) beside it. Under the name sits one or more roles (ds-timeline__role): a header row (ds-timeline__role-header) with the role title (--font-heading-3-*) on the left and a right-aligned, optional date (ds-timeline__role-dates, --font-paragraph-* tertiary) — a current role sets present to render a green "Present" (ds-timeline__present, --color-status-positive-text) in place of the end date. An optional subtitle (ds-timeline__role-subtitle, --font-paragraph-* secondary) sits on its own line under that row for the team, org, or product the role sat in — keeping the title a job title rather than a title and a team punctuated together. Because the title and subtitle read as one header block, whatever follows a subtitle takes a --gap-200 top margin on top of the role's own --gap-200 rhythm, so the body separates from the header at twice the internal spacing. Roles may add an optional description (--font-paragraph-* secondary) and a disc bullet list (ds-timeline__role-bullets, links in --color-action-primary-text-tertiary, the inline-link role Prose uses). The connector is segmented per entry — centred under the 32px logo, starting 8px below it and running down to 8px above the next entry's logo, so every logo sits in an even break rather than having the line pass behind it. The last entry's bar thins to --border-025 and fades to --color-bg-container-primary-transparent at its end — the site's fading-hairline treatment, marking where the timeline runs out.
AreaChart / BarChart / ComboChart / LineChart / PieChart / RadarChart / RadialChart / ScatterChart / StackedBarChart / Treemap
The Recharts wrapper set, sharing one implementation folder (Chart/), one CSS file, and one visual language. A bar is a large filled mass, so Bar, StackedBar and Combo draw it as a wash of its series colour, the way an area fill does, while thin marks (lines, dots, swatches) keep the full token. Every wrapper declares 'use client': recharts renders through React context and state and ships no client boundary of its own, so a wrapper without the directive crashes when rendered from a Server Component (scripts/validate-component-registry.mjs walks each component's import closure and fails a missing directive). Tick and legend text reads --font-caption-size at render (recharts sizes SVG text through an attribute, which cannot hold var()), so a theme's type scale reaches every chart label, RadarChart's radius ticks included; Treemap's tile labels set the same size and the title tier's weight in their inline style, and its tooltip names a tile's value through sizeLabel. Series colours come from the ordered --color-chart-series-1 → -7 ramp as live var() references emitted by the shared palette helper (Chart/palette.ts) — SVG paint resolves them at draw time, so charts follow a mid-session theme switch without re-rendering, and every multi-series chart assigns the same colour to the same slot. In the base wiring, which a theme may re-point, series 1 aliases the action colour (--color-action-primary-bg) — the sanctioned data-viz exception to the action-only rule, theme-split with it — and series 2–7 alias the core accents (mint, gold, coral, violet, amber, cobalt), so re-theming an accent re-themes every chart using its slot. Tooltips float on the glass surface — --color-bg-glass with the documented 24px backdrop blur, a --border-025 hairline in --color-bg-container-border, --radius-200 and --shadow-floating — so the marks underneath stay legible through the overlay; tooltips and legends use system typography tokens. Axes text in --color-text-tertiary. Every chart animates its marks in on mount — bars grow, lines draw, arcs sweep, Recharts' own entrance choreography at its default pacing — and that animation runs from JavaScript, where the CSS token guard cannot reach; the JS-side reduced-motion check the motion contract requires is Recharts' own, not ours: every mark's isAnimationActive defaults to 'auto', which resolves to off under prefers-reduced-motion (and in SSR), so the preference lands the chart directly on its finished frame. Never pass an explicit boolean there without a reason — a literal true would override the guard. A chart animates on mount and on new data; a resize is a re-layout only. Recharts keys an entrance to the computed geometry, so a bare wrapper would replay it for every width step of a dragged panel; the shared hook in src/components/Chart/useChartAnimation.ts holds every series' isAnimationActive to 'auto' from mount, or a change of data, until the series report their end, then to false, so a resize lands the marks straight on their new geometry. ScatterChart is the one recharts item with no end callback: its wrapper paces the sweep itself at MOTION_CHART_ENTRANCE_MS and the hook drops the flag on a timer of the same length. Every chart takes bare (ds-chart--bare), which strips the card chrome — border, padding, fill — for when the chart sits inside a Panel or card that supplies the surface (the composition rules' one-level-of-chrome principle as a prop). ComboChart pairs one bar series with one line series, on a shared axis or with secondaryAxis putting the line on a right-hand scale for pairs in different units (spend and ROAS). AreaChart's automatic multi-series legend can be dropped with showLegend={false} when legend tiles carry the series instead. RadialChart takes centerLabel (+ centerSublabel), a dial headline printed in the donut hole (distinct from the dependency-free Gauge component, which owns the single-reading dial) — pair it with showLegend={false}, since the legend shifts the rings above centre. Each chart's root is role="figure", named by its title heading through aria-labelledby and described by its subtitle through aria-describedby, so a screen reader announces what the chart is before reaching the drawing; a chart with no title renders an unnamed figure, and its surrounding copy has to carry the meaning. Treemap is the one chart that prints text on its own fills, so its cell labels choose their colour per cell: each cell measures the colour it actually paints and takes whichever of --color-text-primary and --color-text-on-inverse (opposite polarity in both themes) contrasts more with it. On a mid-tone fill where even the better token falls under AA's 4.5:1 (both land near 4.3:1 around 22% luminance), the label also wears a 3px halo in the opposite token, so it reads against the halo instead. The pick re-runs when the root's data-theme, data-brand or inline-style overrides change, so a light brand pick or a custom color never swallows a label. It is a client-side measurement; the server markup carries --color-text-primary until hydration.
Contribution graph
ds-contribution-graph — GitHub-style activity heatmap: weeks as columns, weekdays as rows, one 12px cell per day at --radius-050. It wears the chart family's shared chrome — the ds-chart card, header (title/subtitle), and padding come from Chart.css, and bare strips them for a graph inside a panel that supplies the surface (the site's embedded uses are all bare). The legend words come from lessLabel/moreLabel, the month labels from monthLabels, and each day's tooltip from cellLabel. locale (default en-US) writes the default month labels and the date in each cell's tooltip, and sets the weekday each column starts on (weekStartsOn overrides it); monthLabels and cellLabel override either. Cell colour comes from the five-step contribution ramp, defined in both themes:
--color-chart-contribution-0— no activity (--color-bg-container-primarylight / a dark neutral step in dark)--color-chart-contribution-1→-4— increasing activity, green primitives (light: green-02 → 04 → 07 → 09; dark: green-10 → 09 → 08 → 07, so the brightest cell is the ramp's vivid key)
Month labels, caption, and Less→More legend use --font-paragraph-sm-* in --color-text-tertiary/--color-text-secondary. The grid scrolls horizontally inside its own container on narrow screens. The year sweeps in left to right on mount — each column's cells fade in at --motion-duration-slow, delayed by a per-column fraction of --motion-duration-slower (token-composed, so reduced motion collapses the sweep) — and the day under the pointer takes a --color-text-tertiary inset outline so the native tooltip has a visible anchor. This ramp is for activity intensity only — ordered multi-series colours come from the --color-chart-series-* ramp (see the chart set spec above).
Sparkline
ds-sparkline — An inline trend line for stats and table cells, drawn without axes or chrome. Dependency-free SVG computed from props, not a recharts wrapper: no 'use client', no hooks, so it renders from a Server Component and works without the optional recharts peer. It keeps its own folder rather than joining Chart/ — a fit call, not a dependency rule: folder placement and recharts membership are independent axes, and FunnelChart is recharts-backed in its own folder. The series normalises into a padded viewBox (default 120x32; width/height set both the viewBox and the default rendered size, and the SVG scales to its container when sized via CSS), and the line bends through a monotone cubic curve — the same family as the recharts charts' monotone, so a sparkline reads as a miniature LineChart rather than a jagged polyline, and the curve never overshoots a local extreme. Colour flows through color: line, area fill, and end dot all draw with currentColor, set per tone. accent (default) uses --color-chart-series-1 (the chart palette's lead, which aliases the action colour under the sanctioned data-viz exception), positive uses --color-trend-up and error uses --color-trend-down (negative is its legacy name, kept as an alias) (the same trend pair Stat's delta wears — stable hues in both themes, where the status text tints go near-white in dark mode and would wash the two tones into one), neutral uses --color-text-secondary. variant="area" (ds-sparkline--area) repeats the tone colour under the line at 0.15 opacity. showDot (default true) marks the final point with a circle sized off strokeWidth (default 2, an SVG geometry attribute rather than a token); internal viewBox padding keeps stroke and dot from clipping at the edges. The line draws itself on mount the way the recharts lines do — the path normalises to 100 dash units via pathLength and the offset retreats over --motion-duration-slower --motion-ease-emphasized, with the area fill fading behind it and the dot waiting out the draw — all token durations, so reduced motion collapses the sequence. Degenerate data never produces a NaN path: an all-equal series renders a horizontal midline, a single point renders just the dot, an empty array renders nothing. Decorative by default (aria-hidden); passing label switches to role="img" with aria-label.
Gauge
ds-gauge — A radial dial for a single bounded reading: capacity, usage, a score against a target. Like Sparkline, dependency-free SVG with no 'use client', so it renders from a Server Component and keeps its own folder rather than joining Chart/. It wears the chart family's shared chrome — the ds-chart card, header (title/subtitle), and padding come from Chart.css, with bare stripping them inside a Panel; unlike the fluid recharts bodies the dial has intrinsic size, so a bare gauge shrink-wraps for Stat rows and tiles. The dial is a three-quarter circle (270° sweep, gap at the bottom) drawn as two pathLength=100 circles: a quiet --color-bg-container-tertiary track and a value arc in currentColor (default strokeWidth 12, round caps like the family's ring ends), animating between readings via a stroke-dasharray transition at --motion-duration-slow --motion-ease-emphasized and sweeping in from an empty dial on mount (--motion-duration-slower --motion-ease-emphasized, a single keyframe that animates to the element's own dasharray — token-composed, so reduced motion collapses it). Colour flows through color per tone — accent (default) --color-chart-series-1, the family's lead data colour, then the positive/warning/error status border tokens (stable across themes, where the dark-mode text tints are near-white and would wash the tones into one) and neutral --color-text-secondary — and thresholds recolour the dial as the reading crosses them (highest threshold at or below the value wins), which is how a capacity dial goes amber at 70 and red at 90. The centre mirrors the family's radial centre label: the reading in --font-sub-display-* --color-text-display (formatted via formatValue) over an optional caption (--font-caption-*, --color-text-secondary) that doubles as the accessible name. size and strokeWidth are SVG geometry in pixels, not tokens. The dial is announced as role="meter" with aria-valuenow/min/max and the formatted reading as aria-valuetext; a degenerate range (max <= min) draws an empty dial rather than NaN.
FunnelChart
ds-funnel-chart — Ordered stages as a centred funnel of trapezoid bands, built on Recharts' native Funnel (unlike Sparkline and Gauge it joins the recharts side of the family, so it ships from the charts barrel and needs the optional recharts peer), which is what gives it the family's sweep-in mount animation for free — guarded against reduced motion by Recharts' own 'auto' animation default, like the rest of the recharts set. Each band's width is its stage's share of the first, the honest recharts geometry: the taper itself is the conversion story. It wears the chart family's shared chrome — the ds-chart card, header (title/subtitle), and padding come from Chart.css, with bare stripping them inside a Panel. Stage colours cycle --color-chart-series-1 → -7, resolved live in SVG paint; hovering a band raises the family's glass tooltip with the stage name, its share of the first stage, and its reading (displayValue, falling back to the locale-formatted value). Stage names label each band on the right in --color-text-secondary (SVG text pinned to --font-family-primary); showLabels={false} drops them when a LegendTile row under the chart already carries the names. minStageShare, the stepped-bar rendering's height floor, is deprecated and ignored. The chart body is role="img" with a generated label naming every stage and value. The chart's accessible name is summaryLabel(data), "Funnel: " followed by each stage's label and reading, or emptyLabel ("Funnel, no data") when there are no stages.
GanttChart
ds-gantt — Phases and tasks as bars on a shared timeline, with milestone diamonds, optional per-bar progress, month gridlines, and a today rule. Read-only by design: it draws a schedule, it does not edit one. Like Sparkline and Gauge it is dependency-free — computed markup with no 'use client' and no hooks, so it renders from a Server Component, keeps its own folder, and stays out of the recharts barrel. It wears the chart family's shared chrome — the ds-chart card, header (title/subtitle), and padding come from Chart.css, with bare stripping them inside a Panel. Dates are ISO YYYY-MM-DD strings parsed local; the window fits the data (or an explicit range) snapped outward to whole months, and bar geometry is percentage positions over that window — computed coordinates set inline, like the family's other drawn charts. Bars are --radius-pill pills (the ProgressBar/Meter species): per-item accent flows through --ds-gantt-accent, set by a modifier class from the core accent roles — EventCalendar's recipe, with --color-status-neutral-border as the unaccented default. A progress reading turns the bar into a quiet --color-bg-container-tertiary track with the accent filling the completed share (transitioning at --motion-duration-slow --motion-ease-emphasized, token-composed so reduced motion collapses it); projected draws a dashed accent outline instead — a target, not a commitment. Milestones are 12px rotated squares at --radius-050 on their own rows. The today rule is --color-bg-page-inverse with an inverse chip flag, EventCalendar's today convention — today is current, not selected, so the action colour stays reserved for the selected bar's outline (selectedId, paired with onItemClick); today pins the rule to a given day instead of the render-time clock, so a statically built page and its hydrating client can never disagree about where it sits (the roadmap planner template is the worked example). The sticky --color-bg-container-primary label rail pins left while the timeline scrolls in its own container on narrow screens; group headings are overline rows. The structural sizes are component-scoped custom properties a host may retune — --ds-gantt-label-width (172px), --ds-gantt-track-min (480px), --ds-gantt-row-height (36px), --ds-gantt-bar-height (20px) — the CardStack recipe, and how a narrow column (the landing's one-third card) fits the chart without sideways scroll. Honest semantics rather than a claimed grid: each group is a labelled list, the drawing layer is aria-hidden, every bar carries its full reading ("Budget, Mar 9, 2026 to Mar 27, 2026, 65% complete") as tooltip and accessible text, and bars render as buttons only when a click callback exists. Degenerate input — no rows, unparseable or inverted dates — draws an empty body rather than NaN positions. The today flag's text comes from todayLabel. locale (default en-US) writes the axis months and every date in the tooltips and screen-reader text, with the year placed by the locale itself, and the bar reading text is built from rangeLabel(start, end), progressLabel(percent) and projectedLabel, so a translation can change the word order.
LegendTile
ds-legend-tile — The labelled value tile that sits under a chart and ties a series to its number: an optional 8px series dot (any CSS colour via swatch, typically a --color-chart-series-* token), the series name in --font-caption-* secondary with ellipsis, and the reading in --font-paragraph-sm-emphasis-* primary. Fill is --color-label-bg at --radius-300, a soft neutral that holds the tile apart from its panel in both themes. Presentational and server-renderable; a number is written in its locale prop (default en-US).
Globe
ds-globe — An orthographic globe drawn in SVG for the geometry of a network — where things are, what connects them — deliberately without a map library, tile server, or land data: the graticule alone gives the sphere its shape. The drawing lives in a 480-unit viewBox (sphere radius 180, leaving air for lifted arcs) and scales to its container, which keeps aspect-ratio: 1; stroke widths are viewBox geometry like Sparkline's, not tokens. Colour is all tokens: the limb in --color-text-tertiary at reduced opacity, the graticule in --color-divider (back hemisphere runs dashed and fainter), markers in the text ramp (--color-text-secondary, primary when active) unless a point carries its own color — the same per-point channel WorldMap has, so one dataset keys identically on both projections; a coloured marker keeps its colour on hover instead of brightening — labels in the code face at --font-caption-size. Arcs are great circles sampled and lifted by altitude × sin(πt), split into front and back runs (back at 0.15 opacity); the default stroke is a per-arc userSpaceOnUse gradient from --color-chart-series-7 to --color-chart-series-5 — the cobalt-to-violet end of the chart series — and a per-arc color replaces it with a flat stroke. Markers come in two kinds, point (a cross) and anchor (a square), each with an oversized transparent hit circle; the active or hovered point scales its glyph up over --motion-duration-fast and can render an annotation through renderCallout, placed in an HTML overlay beside the marker that flips sides at the disc's centre (data-side), with MapCallout the intended filling. Rotation follows the controlled/uncontrolled pair convention (rotation/defaultRotation/onRotationChange, [lng, lat] degrees, latitude clamped at ±80° so the poles never flip); drag rotates a quarter turn per radius, arrow keys or W/A/S/D step 10° when focused, and autoRotate (degrees per second at the shipped tempo, paced by --motion-duration-orbit so a theme's motion scale quickens or slows it live) spins slowly, pausing on hover, drag, or focus and never running under prefers-reduced-motion (a JS-side check, same recipe as Carousel). Interactive, the root is a focusable role="application" with aria-roledescription="globe"; non-interactive it is role="img". The accessible name appends the point and arc counts and the interaction hint; the SVG itself is aria-hidden. Clickable or interactive markers are real buttons laid over the drawing, named by their label (or id): focus shows the callout, Enter or Space fires onPointClick, and the focus ring is the action-colour outline. The root is role="application" when interactive, group when only clickable, and img otherwise. A press becomes a drag only after 3px of travel, so a marker click is never swallowed. The name parts are pointsLabel, arcsLabel, rotateHintLabel and roleDescription.
MapCallout
ds-map-callout — The annotation beside a point on a map or globe: the place's name in capitals (the --font-overline-* face on the code family) over readout lines in the code face (--font-family-code, --font-caption-*, tabular-nums) in --color-text-tertiary, so a coordinate and a reading line up digit for digit. align="start"|"end" sets which way the block ranges — a callout left of its marker ranges end so it hangs off the point, and Globe's overlay data-side says which to use. Purely presentational — no 'use client', renders from a Server Component. The title prop deliberately shadows the native tooltip attribute.
MapLegend
ds-map-legend — The block in the corner of a map: its name in --font-title-body-*, a line on what it shows in the code face at --font-caption-* secondary, and the key to its markers. The key is a <dl> — glyph as <dt> (aria-hidden; the label carries the meaning), meaning as <dd> — with rows at --gap-200. Built-in glyphs point, anchor, arc, and line are drawn on the same geometry Globe draws its markers with (cross, square, and the same cobalt-to-violet series gradient for the arc), so a globe's key never shows a shape its map does not; WorldMap's filled dot and outlined square are its own, so a flat-map key passes its glyphs as React nodes — line takes a flat color, arc's color replaces its gradient, and any node renders as given for custom swatches. Capped at 32ch so it stays a corner block. Purely presentational — no 'use client'; the title prop deliberately shadows the native tooltip attribute.
WorldMap
ds-world-map — The flat companion to Globe, for when the geography should read at a glance rather than turn: a Web Mercator map drawn in SVG from Natural Earth's 50m land shapes, baked into the package as one path by scripts/sync-worldmap-land.mjs (a deliberate, run-once network fetch — the build never touches the network, and the script owns the projection constants the data was baked with). Still no map library and no tile server: the land is data, so the whole map themes with the tokens — fill --color-bg-container-tertiary with a --color-divider coastline, the graticule in --color-divider at half opacity, both on vector-effect: non-scaling-stroke so hairlines stay hairlines at any framing. The root fills its container — size a wrapper, never the root through className, whose height would tie with the component's own fill rule at equal specificity and lose in some stylesheet orders (the 2026-09-17 production lesson: the showcase maps rendered at 0px height while dev looked fine). The drawing space is a 0..360 square (one unit per degree of longitude, latitude clamped at ±85.05°), so the bounds prop — [west, south, east, north], never crossing the antimeridian — becomes the viewBox directly; fit="cover" fills a stage edge to edge the way contain letterboxes, implemented by fitting an aspect-true frame to the measured container (ResizeObserver) so marker percentages stay exact. Markers are HTML buttons over the drawing, not SVG: hover, focus, and click all work, each carries its point's label as its accessible name, and each can carry its own color (any CSS colour, typically a --color-chart-series-* token) through a custom property — the flat map's channel for colour-coding a category, where Globe puts colour on its arcs. The 20px hit target around a 10px glyph is drawing geometry, not spacing, like ToggleSwitch's thumb; point is a filled dot ringed in --color-bg-container-primary (a contrast ring, not an elevation shadow), anchor the family's outlined square at --radius-050. The active or hovered marker scales its glyph over --motion-duration-fast and can render an annotation through renderCallout — the same overlay contract as Globe (data-side, MapCallout the intended filling), opening toward the map's centre where the room is. renderHoverCard is the floating-panel alternative: the map composes the library's HoverCard around each marker (a position slot owns the placement so HoverCard keeps its own layout contract, and the marker colour inherits down), opening on hover or focus with the system's delays and Escape dismissal, opening toward the map's centre (downward from the upper half, upward from the lower), with the hovered or focused slot rising above its neighbours; content must stay phrasing-level, the panel's own rule, and when both renderers are given the hover card wins. showZoomControls adds a navigable view inside bounds, with the website's diagram-pill controls brought into the component: a glass pill floating at the bottom centre (--color-bg-container-primary-semi over a 24px backdrop blur, hairline --color-divider border, --shadow-floating) holding zoom out, a tabular-nums percent readout that resets to 100% on click, and zoom in — composed compact tertiary CircularButtons, the architecture maps' recipe. Steps are 1.5× up to maxZoom and down past 100% to a computed floor — the whole world in the window with air around it — the readout resets to the given frame, ctrl/⌘ + wheel (a trackpad pinch) zooms at the pointer while a plain scroll keeps scrolling the page (a native non-passive listener, since preventDefault is load-bearing), and the frame pans by drag and by arrow keys with the root focused (+/- zooming) — Globe's drag-and-keys contract translated from rotation to pan. Panning roams the whole world at any zoom, clamped to the world's extent: bounds is the view a region starts and resets at, not a wall. Mouse and pen grab whenever there is world beyond the window; a finger only drags while zoomed in (touch-action locks only then), so an unzoomed map never swallows a phone's page scroll — the architecture maps' embedded contract. The graticule covers the window rather than the world for the same reason: meridians continue past the antimeridian and parallels past ±80° at the step's own spacing, so panning or zooming out never runs off the lined paper — the map just gains space around it. The view is keyed to bounds, so a region change resets to zoom 1; a drag that started on a marker is never a click (a small movement threshold decides). Interactive, the root is a labelled role="group" of buttons (focusable itself only when the zoom keys are live); interactive={false} renders inert glyphs under role="img". The accessible name appends the drawn point count and, with zoom on, the navigation hint; the SVG itself is aria-hidden. The zoom toolbar and its buttons take zoomControlsLabel, zoomOutLabel, zoomInLabel and zoomResetLabel; the map's name appends pointsLabel(count), plus zoomHintLabel when zoom is on.
ActionCard
ds-action-card — A change an assistant proposes and the reader confirms: the card the chat leaves in the thread before it changes anything, and the record of what happened after. A content card of the chat furniture, on ArtifactCard's surface (--color-bg-container-primary behind a --border-025 --color-bg-container-border hairline at --radius-600), so the tiles a reply leaves read as one family, inside --padding-300 --padding-500, the stacked chat cards' padding, so its text starts where the reply's does (a one-row record takes a chat bubble's --padding-300 --padding-500). The title and its detail sit --gap-200 apart as one text block, and the buttons stand 16px off them, the seam every chat card puts between its text and its actions. Proposed, it stacks the title in --font-title-body-*, the chat cards' one title tier, with an optional icon at --icon-size-500 trailing at the row's end (in the one-row records too), so the text starts on the card's inset edge, a detail line in --font-paragraph-sm-* --color-text-secondary saying what the change leaves alone and why, and Confirm (a compact primary Button, in tone: destructive for a change that takes something away) beside Dismiss (compact secondary neutral). The second button reads Dismiss, never Cancel, which beside a cancellation reads as the action itself.
Fully controlled by design, with no uncontrolled default to pair: the host makes the change, so the host owns whether it was made. Hook-free, so the records render from a Server Component. status is proposed, confirmed, dismissed or undone; onConfirm makes the change through the same action the host's own buttons use and sets confirmed, and the card never changes anything itself. Every other status is one row, the record (unless a confirmed card keeps its facts, below), carried as a role="status" so a confirmation is heard as well as seen: confirmed shows receipt (the title by default) with Undo at its end while onUndo is set (a compact tertiary Button behind the undo glyph, which takes the trailing slot, so the card's icon steps aside), and dismissed and undone show the title with dismissedLabel ("Not changed") or undoneLabel ("Undone") in --color-text-tertiary. Every word is a prop (confirmLabel, dismissLabel, undoLabel too).
facts lists what the change will do, a line each, in ActionReadout's two columns a text gap under the title and detail, so the reader confirms with the numbers in front of them. A confirmed card given facts keeps them and stays a card rather than a row: the receipt in the title tier and Undo share a head a compact control tall, with no icon, since the row's end is Undo's and a host may hold Undo back (the merchant screen does until the reply describing the change has finished), and the facts sit under it, over a read-out of the decision where Confirm stood (confirmedLabel, "Confirmed", behind a check, the recipe's read-out in the positive status), so the proposal and its record share one shape. A dismissed or undone card is one row and shows no facts. The host words the facts for the state the card is in ("Refund" proposed, "Refunded" confirmed).
ActionReadout
ds-action-readout — What a confirmed change did, as the opening of the reply its confirmation gets: a title in --font-title-body-*, with an optional icon trailing at its end, over facts, a description list in two columns (max-content labels in --color-text-secondary, values in --color-text-primary that wrap inside their column rather than widen the card). On ActionCard's surface and --padding-300 --padding-500 padding, so a proposal and its readout read as one family. The host works the facts out from the records as they were just before the change, written in the reader's locale, so the readout is exact and needs no model; the assistant's words follow it. A proposal that carries its own facts can be its record instead, as an ActionCard confirmed with them. Presentational and hook-free, so it renders from a Server Component.
AgentPlan
ds-agent-plan — The steps an agent intends to take and where it has got to. It completes the agent vocabulary: AgentStatus says what is happening right now, ToolCall records one action, AgentPlan holds the whole task. Same container recipe as ToolCall — --color-chat-card-bg at the chat furniture's shared --radius-600 shell with a hairline --color-bg-container-border, content inset --padding-500 from the sides — and the same 0fr → 1fr grid collapse for the panel, opened by default. The header button carries a computed title ("3 steps left", or "All steps complete"; title overrides), a tabular-nums completed/total readout in --color-text-tertiary, and the rotating expand_more chevron. The computed header comes from remainingLabel(remaining) ("N steps left") and completeLabel ("All steps complete"), and each step's hidden status text from statusLabels, merged over Not started, In progress, Completed and Failed; a title replaces the computed header.
Steps render as an <ol> with one row per step: an indicator in a fixed --icon-size-500 box (the active step's sm Spinner is smaller than the step icons, and the fixed box keeps every state centred on the same axis), the label, and an optional detail line in --color-text-tertiary. Four statuses, coloured through the plain-surface --color-status-*-icon steps — pending (circle, neutral), active (Spinner at tone="inherit" in info, label bumped to --font-paragraph-sm-emphasis-*, aria-current="step"), completed (check_circle in positive, label receding to --color-text-tertiary), failed (error in error, label in --color-status-error-text). The container stays neutral like ToolCall — colour lives in the indicators, and a thin --color-divider connector ties them into one path through the task. Status is colour + glyph for sighted users; a visually hidden span announces it per step.
AgentRail
ds-agent-rail — The companion rail of an agent product, and the mirror of ThreadPanel: where the left rail holds the sessions a person has had, this one holds the agent itself — who it is at the top, and the record of what it has been doing below, split across tabs. Deliberately surface-less like its sibling, so the host paints the band it sits on, and it scrolls exactly one region — the active pane — behind the same tucked-away thin scrollbar (scrollbar-width: thin, no track fill, the --color-divider thumb showing only under the pointer). The rail owns its width: 360px, set by the widest thing in it, which is the tab strip rather than the rows — four labelled tabs measure a little over 310px, and a rail that clips its own navigation is not a rail. The sibling history gets away with 280 because a thread row is one line; here a row carries a title, a second line and a time as well. A host that wants another width overrides it.
The header is a centred identity block, not a row: the portrait — an lg Avatar when avatarSrc is given, the rail's own smart_toy mark on Avatar's exact footprint and fill when it is not (initials would claim a person, which is the one thing the subject here is not), or any element through avatar for a product whose agent has a face of its own — over the name in --font-title-body-* and an optional status line in --font-caption-* on --color-text-secondary, led by StatusDot's xs mark restated (a --gap-200 dot — the gap scale, because no icon token is that small) in any of the five --color-status-*-icon roles. onCollapse — for a host that wants a way out on the rail itself — floats a chevron_right over the rail's top leading corner, wearing a tertiary CircularButton's exact footprint and resting treatment (40px, --icon-size-600, --color-action-passive-bg under --color-icon-primary), because that is what it ends up in a row with rather than seating it in the header, for two reasons: the header is a centred identity block, so a control in that column would push the portrait off the rail's axis, and the trailing corner belongs to the host — a rail seated at a card's edge is where the card's own controls end up, and the collapse would sit under them. A chevron by default rather than a cross: the host's own close control is usually a cross a few pixels away, and two of them side by side can read as two ways to dismiss the same thing. The way back in belongs to the host. onEdit hangs a 24px circular edit affordance on the portrait's lower-right corner — the seat an Avatar's own status dot would take, so the two are alternates rather than a stack — filled --color-bg-container-primary inside a hairline --color-bg-container-border so the circle reads as lifted off the face beneath it, washing to --color-action-passive-bg-hover. The pencil renders only when the callback is wired. With no profile at all there is no header for the chevron to float over, so the rail reserves the control's own row above the strip rather than letting it land on the first tab. collapseVariant="close" draws the same control as a close cross in the top trailing corner instead, for a host that has decided its own controls and the rail's do not collide; the chat widget's built-in agent pane takes it, so the pane closes from its own corner as well as from the header's toggle.
Tabs are a content-sized compact neutral SegmentedControl in collapse mode, centred in the rail, which brings the role="tablist" semantics, the sliding pill, arrow-key traversal and the responsive ladder with it. The ladder is what makes four tabs fit a rail at all: icon and label together need 423px, labels alone 311, icons alone a little over 200, and the control measures all three from its own first render and takes the widest that fits. So the shipped 360px rail shows labels, a host with room shows both, and a squeezed one — the bottom sheet on a small phone, a narrower host override — falls to glyphs with the words still in the accessibility tree. Decoration goes before meaning, which is why the icon is shed first and the label last. The pane below is a role="tabpanel" named by the active tab's label and carrying tabIndex={0}, since it is the rail's only scroller and may hold nothing focusable at all; the header and the strip stay put while it changes, so switching tabs never moves the agent's face. Panes are host content — the four the pattern is named for (activity, approvals, automations, personalization) compose NotificationItem rows and EmptyState rather than shapes of this component's own, which is what keeps an activity row and a notification the same row.
Fully controlled and hook-free: the host owns which tab is on stage (value/onValueChange, with activeTab and onTabChange kept as deprecated aliases), and the component carries no 'use client', so a rail whose panes are static renders from a Server Component. footer seats host furniture below the scroll, above a --color-divider rule.
ArtifactCard
ds-artifact-card — An artifact's place in a conversation: the tile a reply leaves in the thread for something too big for a turn, which opens it in an ArtifactPanel. One row at the full width of the column, like every chat card: a 40px mark on --color-bg-container-secondary, its radius concentric with the card's (--radius-600 less the --padding-300 between them, computed from the live tokens so the corner holds under any theme's radius scale) holding an --icon-size-600 glyph (icon, defaulting to draft, or any element), the title in --font-title-body-* over an optional meta line in --font-caption-* (the two lines together are the mark's 40px) on --color-text-tertiary, both ending in an ellipsis rather than growing the row, and a trailing --icon-size-500 cue on --color-icon-secondary: right_panel_open, or right_panel_close while its artifact is on stage, the chat widget's artifact toggle's own glyphs (mirrored in a right-to-left layout), so the card and the header control that open the same panel draw the same mark. The row is a --radius-600 card, the chat's card-furniture radius, with a --border-025 --color-bg-container-border edge on --color-chat-card-bg, washing to --color-action-passive-bg-hover on hover over the fast duration, with the house focus ring.
It is a toggle button and nothing more: a real <button> carrying aria-pressed, with open the whole state and onClick the whole behaviour, so the host decides what opening means. Open, it holds --color-bg-container-tertiary, the "on stage" fill ThreadPanel's active row and ThreadTabs' active tab wear, and the cue turns from right_panel_open to right_panel_close; the cue is decorative, since the pressed state already says it. Hook-free and stateless, with no 'use client'. Disabled follows the house rule.
ArtifactPanel
ds-artifact-panel — The surface a chat opens beside the conversation to show something it made: a dashboard, a document, a page, a site. Deliberately surface-less like AgentRail and ThreadPanel, so the host paints the card it sits in and decides where that card goes; the panel fills the box it is given (width and height at 100%) and scrolls exactly one region, the body. A <section> named by its own title through aria-labelledby. expanded is fully controlled by design, with no defaultExpanded: only the host can change the size it names, and ChatArtifactPane passes it straight through for the same reason. codeBlockLabels passes the Code view's copy and region names through.
The header is one row of 40px controls standing --padding-300 in from the top and trailing edges, the inset AgentRail gives its own corner control, so the two panels that share a chat's trailing seat put their close in the same place and a rounded host card has room to turn its corner around it, over a --border-025 --color-bg-container-border rule. Leading: an optional --icon-size-500 mark on --color-icon-secondary, the title as an <h2> in ChatHeader's --font-title-body-* face, and an optional meta line in --font-caption-* on --color-text-tertiary. The name outranks its meta when the row runs short: the meta carries a shrink factor large enough to give way entirely before the title loses a letter, and the title then ends in an ellipsis. Trailing: a compact neutral SegmentedControl switching Preview and Code (labels only, and only when the artifact has both), the host's own actions, then two tertiary CircularButtons with downward tooltips, the expand toggle (expand_content / collapse_content, aria-pressed, rendered only when onExpandedChange is given) and the close (close, rendered only when onClose is given). The panel never sizes or dismisses itself: expanded only picks the toggle's icon and name, and both callbacks report intent to the host that owns the seat. Every label the component writes is a prop, so none of its English is fixed.
The body is one of three previews or the source. children is live React content, scrolling inside the body and bringing its own padding (a dashboard wants an inset, a full-bleed page does not); html is a complete document drawn in a frame through srcDoc; src is a URL drawn in a frame. children wins over html, which wins over src. Both frames carry sandbox="allow-scripts allow-forms" by default and referrerPolicy="no-referrer": the document runs in an origin of its own with no reach into the host page, which is the right default for output a model wrote, and sandbox widens it only for a source the host trusts. A framed document is somebody else's page, so it fills the body edge to edge and owns its own scrolling and its own colours; what it can do is follow the reader's scheme, since a frame reads prefers-color-scheme from the color-scheme of the element that embeds it, so a host whose theme sets color-scheme gets a preview that turns light or dark with it (the demo's page does). code (with language) adds the Code view, a CodeBlock inset by --padding-400 with its own copy button; with no preview the source is the whole artifact and the switch does not render. The view is controlled through view or kept by the panel from defaultView, and a view the artifact cannot show falls back to the one it can, so a host swapping artifacts under a standing choice never lands on an empty body. The scrolling regions wear ThreadPanel's tucked-away thin scrollbar.
AgentStatus
ds-agent-status — What an agent is doing right now: a dot-matrix indicator beside a line of status text. The matrix is a 4×3 grid of 12 --radius-pill dots sized off the icon scale (--icon-size-600 wide at the default size, --icon-size-500 at compact) with --gap-050 between them, so the dots derive their size from the grid rather than carrying pixel values of their own. The label is set in --font-title-body-* — a status is a heading for the work underneath it, not prose. compact keeps the --font-paragraph-sm-* metrics (the scale has no 14px title face) but carries the same --font-title-body-weight, so one size of the component can't read as a heading and the other as prose; the size still pairs with ChatMessage's default and compact message text (sm/md remain as legacy aliases). The default size costs nothing in layout: the title-body and paragraph sets share 16px/24px metrics, so only weight and tracking change.
The named patterns — AgentStatusPatterns.tsx is their authoritative list (braille, orbit, breathe, snake, and the rest) — are choreography over that one grid. Every pattern runs on the same twelve-slot cycle (--motion-duration-loop-matrix, 1400ms): each dot is told which slot it lights in via --ds-agent-step, and dots a pattern never lights opt out of the animation. One grid and one cycle length means changing pattern never changes the indicator's footprint or its rhythm.
Six states: idle, thinking, working, waiting, done, error. The three working states are deliberately monochrome (--color-text-secondary, --color-text-tertiary when idle) — colour is reserved for the states where it carries meaning: the dots take --color-status-warning-icon, --color-status-positive-icon and --color-status-error-icon (they are a mark, and a theme may set its status text near the page's ink), and the label the matching --color-status-*-text. The terminal states fill the matrix solid so the shape itself reads as finished, and twinkle: on a fixed scatter at a sixth of the cycle's pace, a dot at a time dips to 0.6 and back, well under once a second across the matrix, a flicker short and shallow enough that a finished status stays alive without drawing the eye. twinkle is also a pattern of its own, the same flicker over a solid matrix, for a live state that should read as calm. While active, a sweep walks the label left to right (--motion-duration-loop-shimmer over a background-clip: text gradient from --ds-agent-shimmer-base, default --color-text-tertiary, through --ds-agent-shimmer-sweep, default --color-text-primary; waiting keeps its warning base and sweeps --color-text-primary); shimmer overrides the default. The base is the dimmer colour and the sweep the brighter one, so what travels is a highlight rather than a shadow — the reading waiting always had, now the rule. The band is narrow, 12% of a 250%-wide image and so under a third of the label at a time: spread wide it washes the whole label at once and stops registering as movement at all. When the label string changes, the outgoing text fades up and out while the incoming fades up and in over --motion-duration-slow — a status that narrates progress glides between steps rather than snapping. The shimmer's text clip stands down for the swap (an animated descendant composites on its own layer, which escapes background-clip: text) and resumes on the settled text; children are consumer-owned markup and render without the transition. Every colour on the component routes through custom properties — --ds-agent-color for the dots, --ds-agent-label-color for the text, plus the shimmer pair — so a consumer can tint a row (e.g. one --color-core-accent-* per agent when several share a surface; the showcase page demos one variation per core accent).
variant="bar" wraps the row in a full-width card surface (--color-bg-container-primary behind a --color-bg-container-border hairline) at --radius-300 for the top of a panel. The root is a role="status" live region, and the matrix is aria-hidden, so the state change is announced once as text rather than as twelve dots. Under prefers-reduced-motion the matrix parks at a legible static opacity and the shimmer resolves to flat text — the global guard would otherwise collapse every dot to its dark final frame.
AgentStatusMark (ds-agent-status-mark), exported beside the component, is the dots alone for an icon slot, drawn once so every icon-sized "the agent is working" looks the same. It is a square 4×4 grid sized by the inherited --icon-size (--icon-size-600 when none is set), and inset by a twelfth of that on every side, the margin a Material Symbol keeps inside its em box, so it swaps with a glyph without moving or reading larger than it. Every AgentStatus pattern is redrawn for the square on the matrix's own twelve-slot cycle (sixteen dots land on fractional slots, so the rhythm is unchanged); orbit is the default, twelve edge dots clockwise round four dim core dots, and waiting defaults to breathe, a still pulse of every dot that reads as the person's turn rather than work in progress; the states, colours, solid terminal fill and reduced-motion parking are AgentStatus's. It is aria-hidden: its host announces the state.
variant="pill" is a glass status for a run that goes on while the person keeps working: --color-bg-glass behind a --border-025 --color-bg-container-border hairline at --radius-pill, on --shadow-floating, with a light component-local frost. The label truncates rather than widening the pill past its host. Once the run ends the dots fill solid in the status colour, as in every variant, so a finished pill reads green or red before its label is read. action seats a trailing control after the text (a link that opens the run), for the pill and bar variants. The pill does not position itself: the host seats it, and a shell can give it a home.
The agent ring (ds-agent-ring, set with agentRingProps(state) from AgentRing.tsx beside the component, which also carries the stylesheet) marks whatever an agent is working on: a section, a whole table, a row, a card, a field, a panel. It is a class and a data-agent-state attribute rather than a component, because a wrapper cannot go around a table row and a class works on any element. It is AiButton's ring recipe, the AI gradient masked to a --border-050 band, drawn with the element's ::after so it never moves layout, and it takes the element's radius. Two hooks let any surface wear it: --ds-agent-ring-radius gives a square surface corners, and --ds-agent-ring-offset stands the ring clear of a borderless one. thinking and working turn it once per --motion-duration-loop-spin; waiting takes --color-status-warning-border and breathes on --motion-duration-loop-matrix, the dots' own cycle, between 0.45 and full strength (the dots dim further, but a ring that faded that far would read as switching off); done and error take --color-status-*-border for the same 2s beat AiButton holds, then fade off, leaving the element as it was. The ring is colour only, never the action colour, and the fill stays the host's. An element that already uses ::after, and a replaced element (an input, a textarea, a select, an image), takes the ring on a wrapper. prefers-reduced-motion stops the turn and the breath; the ring and the done and error fade stay.
One vocabulary across every agent surface. AgentStatus, the square mark, the pill, the agent ring and AiButton read a state the same way, so the person learns it once. idle: dim, still (--color-text-tertiary). thinking and working: monochrome and travelling (the chosen pattern, orbit by default; the ring turns). waiting: the warning colour, breathing in place on the twelve-slot cycle between 0.45 and full strength, dots and ring alike (a lower floor lets the mark all but vanish at the bottom of each breath) (breathe is every dot surface's default while waiting; the ring breathes on the same cycle). done and error: the positive or error colour, filled solid (the dots) or for a beat (the ring, and AiButton's field), with nothing moving. Dots and rings take the --color-status-*-icon and --color-status-*-border steps, never the text steps, which a theme may set near the page's ink; labels take the text steps. Under prefers-reduced-motion every surface stops moving and keeps its colour.
AiButton
ds-ai-button — The AI entry point: icon and label on a solid --color-ai-button-bg field — the mode's extreme surface, lighter than the light floor and deeper than the dark one, so the pill stands off any page floor — ringed by the AI gradient and haloed by a soft glow of the same gradient escaping behind the opaque fill. The ring is the system's signal for "a model answers here": ordinary actions keep the flat --color-action-primary-bg, and this treatment is reserved for AI surfaces so neither affordance dilutes the other. It is a pill (--radius-pill, the button invariant) on Button's control scale — same padding tokens, same --font-paragraph-emphasis-* face, default and compact sizes, the icon on --icon-size-600 at default and --icon-size-500 at compact, where the label also steps down to --font-paragraph-sm-emphasis-*. The summary panel's region, dismiss button and prompt row are named by summaryRegionLabel, dismissLabel and suggestionsLabel.
The three stops are the AI gradient tokens (see AI in the Colors chapter for the role and its per-theme values). The ring paints a conic gradient of the three stops (start → mid → end → back to start, so the seam at 360° is invisible) across a ::before, masked to a band with an exclude-composited two-fill mask. At rest the band is a --border-025 hairline at 0.6 opacity, so the resting button stays quiet; hover, a busy status and an open panel (the trigger's aria-expanded, set by Popover, a sheet or the button's own request panel) widen it to --border-050 at full strength over --motion-duration-base, so the ring wakes when the button is in play and holds while what it opened is open. Keyboard focus leaves it at rest: the focus outline is focus's signal, as on Button, and a widened ring inside it would read as two concentric strokes. Rotation animates a registered @property angle on a deliberately off-scale 21s loop of three uneven turns — keyframes at uneven angles, eased per segment with --motion-ease-emphasized, so the ring drifts, surges and settles rather than ticking round at one rate, ending on a whole turn so the wrap is seamless and the average pace stays one turn per 7s (sanctioned in place with ds-allow(motion): ambient signature motion, far slower than the interaction tokens); browsers without @property get a static ring. The glow is a ::after of the same gradient blurred (a component-local --ds-ai-button-glow length, chosen by eye) at 0.3 opacity, rising to 0.55 with the ring — a colour effect, not an elevation shadow, so depth stays token-owned. On mount the pair plays a one-shot entry: the glow blooms out of the bottom-right corner (scale from a corner transform-origin, opacity from 0, --motion-duration-slower on --motion-ease-entrance) and the ring fades up into place a --motion-duration-fast beat behind it, so the button lights rather than arriving at full strength — from-only keyframes settling onto the base styles, composed alongside the turn as a second animation; a disabled button pauses only the turn, letting the entry complete. Focus is the standard --color-action-primary-bg ring; disabled drops to 0.4 opacity and pauses the rotation; prefers-reduced-motion stops the turning and keeps the ring and glow. Renders <button> or, with href, <a>.
state reports the agent behind the button in AgentStatus's six states, the prop name AgentStatus, AgentStatusMark and agentRingProps share, and the label never changes; the icon carries the state. The icon slot is a fixed --icon-size square, so no state's icon moves the label or the edge. thinking and working show AgentStatusMark, swap the ring's drifting cadence for a steady linear spin of one turn per --motion-duration-loop-spin, set aria-busy, and hold the summary panel shut. waiting shows the mark in --color-status-warning-icon, breathing rather than orbiting, with the ring at rest: the next move is the person's, so nothing travels and the button only pulses. done and error turn the whole button to their status family for MOTION_FEEDBACK_RESET_MS: the field takes --color-status-*-bg, the ring and glow --color-status-*-border at full strength (the ring's stops are component-local properties, so one rule recolours both), and the icon is AgentStatusMark filled solid in --color-status-*-icon, the same finished mark AgentStatus shows; then the AI look and the resting icon return while the host's state stays. Each status icon fades and settles in over --motion-duration-base on --motion-ease-entrance. The button stays clickable throughout, unlike Button's loading, so a click can open the conversation to watch. A visually hidden polite region beside the control (never inside it, where it would join the button's name) announces each state in the words of statusLabels, a record merged over the English defaults as the chat's other status words are. prefers-reduced-motion stops the spin, the mark's cycle and the icon's entrance; the swap still shows the state.
With summary, the button summons a TLDR panel (ds-ai-button__summary): a frosted glass card (--color-bg-glass thinned in light via a sanctioned color-mix, heavy blur sanctioned in place) at --radius-600 with the ordinary --border-025 container border, opening above or below per summaryPlacement and right-aligned to the button. Button and panel live in one hover host (ds-ai-button-host), so pointer travel between them never closes the panel: it opens after MOTION_HOVER_SHOW_DELAY_MS, hides MOTION_HOVER_EXIT_GRACE_MS after the pointer leaves the whole host, and summaryPinned holds it open for callers with their own reveal signal. On open the AI gradient sweeps once around the panel's border (the button's masked conic ring run as a single 0→360° pass, then fading out — a registered @property angle, one-shot), the button gives one --motion-ease-spring acknowledge pulse, and the content rows stagger up on --motion-ease-entrance. The panel fakes its generation once per mount: Skeleton lines for MOTION_SUMMARY_THINK_MS, then the pre-written text through StreamingText within MOTION_SUMMARY_REVEAL_MS; later openings show the text instantly, as if cached. The overline sits in the --font-overline-* face, the title in --font-title-body-*, the text in --font-paragraph-*, and the chips are PromptSuggestions in its stacked compact form. The chips wait for the reveal to finish — the same "here is something you could ask" beat the chat's follow-ups take; showing them mid-think would give the staging away. During the type-out an invisible ghost of the finished text sizes the container, so the stream never jumps the panel and a short summary reserves no space it won't use. The panel stays mounted and hides on visibility — HoverCard's approach — so closing eases back down the path the entrance sprang up while the hidden panel stays out of the accessibility tree and the tab order. A corner dismiss button closes it for the visit and fires onSummaryDismiss; leaving the host re-arms it, so the next hover summons it fresh. The card caps at a component-local 340px measure (minus a phone margin) on --shadow-floating. prefers-reduced-motion drops every panel animation; the think beat still stages, being a schedule timing, while StreamingText's own check makes the reveal instant.
With request, the button carries what the agent needs from the person while state is waiting, in a request panel (ds-ai-button__request) at the button, so the question appears where the person is already looking. The panel is the summary's glass surface, opening sweep and pop, with its own requestPlacement, and it is the only surface: it adds no padding, and the content brings its own spacing. An InterruptCard approval with bare is the usual filling, which drops the card's own fill, border and radius so the approval reads as the panel itself with one border; a question, a form or a preview work the same way, padded by the host. It opens by itself each time the state moves into waiting (it never waits for hover), and it is a non-modal layer on the dismissal stack: Escape folds it back into the button, as does a click on the button the host's onClick does not prevent, and the next click opens it again, with aria-expanded and aria-controls on the button saying so. There is no light dismiss, since a stray click should not hide a pending decision, and no corner close, since the content is the host's. Open state is the requestOpen/defaultRequestOpen/onRequestOpenChange pair, so a host can keep it shut while its chat shows the same request. While it is open the summary panel stays shut.
ChatHeader
ds-chat-header — The top row of a chat surface: the conversation's name on the left, its controls on the right. A space-between flex row padded --padding-150 --padding-400 at --gap-400, deliberately drawing no bottom border — like Composer at the other end, it floats free of the transcript, and the thread's own top padding provides the separation. A string title renders in the --font-title-body-* set in --color-text-primary, truncating with an ellipsis so a long conversation name never wraps the row; a node title renders as-is for consumers who need their own heading element. The title prop deliberately shadows the native tooltip attribute — a header's title is content.
actions is a trailing flex row at --gap-100 that only lays controls out: the intended fillings are tertiary CircularButtons (new chat, close) and a view-switch toggle, each owning its own behaviour and accessible name. The component is purely presentational — no 'use client' — so it renders from a Server Component.
ChatMarker
ds-chat-marker — The inline separator a conversation uses for anything that is not a turn: date breaks, joins, mode changes, system notes. A --font-paragraph-sm-* label in --color-text-tertiary sits between two flanking --border-025 lines in --color-divider (--gap-300 off the text), so the row reads as furniture rather than as a message. An optional leading Material Symbol renders at --icon-size-500 and is aria-hidden — the label carries the meaning. line={false} keeps the flanking segments as transparent spacers, so a bare note stays centred on the same geometry.
The root is role="separator" with the label as its content: screen readers treat it as a boundary, not a message, matching how the eye skips it while scanning turns. System events belong here; system messages with content are ChatMessage's job.
ChatMessage
ds-chat-message — A single chat turn: avatar, author, timestamp, and the content itself, aligned by role. User turns are right-aligned bubbles; assistant turns are surface-less full-width text in --color-text-primary, so a transcript keeps its question-and-answer rhythm without every row wearing a surface. The bubble prop overrides the role default in either direction: an assistant turn can take a received bubble for classic messenger layouts, a user turn can go plain. Bubble surfaces come only from the four chat bubble tokens — sent bubbles pair --color-chat-bubble-sent-bg with --color-chat-bubble-sent-text, received bubbles the matching received pair — never the container ramp directly, so re-theming a chat means repointing those four tokens and nothing else. The action colour never appears as a bubble fill: an action-coloured bubble would spend the "click here" signal on something that is not clickable.
Bubbles sit at --radius-600 with --padding-300 --padding-500 padding and cap at --ds-chat-message-max-width (default 75%), a consumer-override hook; tail squares the speaker-side bottom corner to --radius-100 — bottom-right on sent, bottom-left on received — a corner treatment only, with no pseudo-element pointer. Content sits on the --font-paragraph-* set; size="compact" drops it to --font-paragraph-sm-* and tightens bubble padding. Content is width-locked to its container — min-width: 0, max-width: 100% and overflow-wrap: anywhere on the content block, so an unbroken run (a URL, a token) wraps inside the bubble or column instead of pushing the surface wider. The meta row is the author in --font-paragraph-sm-emphasis-* --color-text-secondary beside a free-text tabular-nums timestamp in --color-text-tertiary. The avatar gutter is a fixed --ds-chat-message-gutter (default --icon-size-800, matching Avatar's small size): showAvatar={false} and grouped hide the avatar but keep the gutter, so consecutive rows in a run stay aligned; grouped also drops the meta row and pulls the row up towards the one above. Card furniture inside a turn (MessageCard, InterruptCard, ToolCall as direct content children) takes --gap-300 of clearance from the content before and after it — margins, not gap, so a card standing alone in a turn adds nothing.
pending replaces the content with three --color-text-tertiary dots pulsing in a staggered wave on --motion-duration-loop-matrix, wrapped in role="status" with a visually hidden pendingLabel and the dots aria-hidden; under prefers-reduced-motion the dots park at a legible static opacity rather than freezing at the loop's dim frame. actions renders under the content, revealed on hover and :focus-within over --motion-duration-fast — and always visible where hover does not exist (hover: none), so touch users are never locked out; showActions pins the row on permanently, for surfaces where the actions are part of the response (the site chat's copy and thumbs row) rather than an affordance to discover. attachments holds the files sent with the turn, an AttachmentGroup drawn outside the bubble, above the text, on the speaker's side; a turn that carries files and no text draws no bubble at all, rather than an empty padded one, and a compact thread seats smaller tiles. footer is an always-visible slot for sources or an edited note. The package ships no markdown renderer: render markdown yourself, ideally wrapped in Prose, and pass the result as children.
ChatThread
ds-chat-thread — The scrollable conversation column, and the home of the chat surface's most important interaction: the send choreography. The root is the scroll container; turns stack inside an inner __content wrapper (flex column at --gap-1000, padded --padding-500 with the inline gutters routed through --ds-chat-thread-padding-inline as a consumer-override hook for mobile densities), and every entry is flex-shrink: 0 so children that clip their own overflow never collapse. A second hook, --ds-chat-thread-content-max-width, caps the conversation column and centres it — for wide surfaces where the container grows but the column should not — while the scroll container, its fades, and the scrollbar keep spanning the full component width. Both ends fade through a pure-CSS mask-image gradient with --gap-500 stops — the PromptSuggestions treatment turned vertical — so content scrolling out passes through the fade with no scroll listeners involved (the mask alpha keywords carry a ds-allow(color) sanction).
On append, a newly added turn floats to the top: the component sizes a trailing spacer so the first new child can sit exactly at the top of the viewport — even when the newest exchange is short — then scrolls to it, pushing the prior conversation upward. The glide comes from scroll-behavior: smooth, which the reduced-motion guard in the motion tokens forces back to instant; because content streaming in below the viewport never moves the scroll position, the anchored turn stays put while a response grows beneath it. When a user turn and the agent's pending turn are appended in one update, the user turn is the anchor. The first send into an empty thread has no scroll distance yet, so it gets the same feel from a transform instead: the new turn starts at composer level and floats up to the anchor position over --motion-duration-slower --motion-ease-entrance (a thread that mounts with a restored transcript instead opens instantly at the latest message, re-establishing the anchor). The spacer holds exactly the shortfall between the anchor and what the content can already reach, tracked in both directions for the whole exchange, so the scroll range ends precisely at the anchor and never past it. That is what keeps the dead space honest: reserved room a growing answer has since filled is given back as it goes, so the space is gone by the time the answer lands rather than collapsing after it — no jump, because the range it defines never moves. Growing it back is the direction that needs the clamp guard: content shrinking mid-exchange pulls the range under the anchor a frame before a ResizeObserver can react, so the position is restored alongside the range. The send has a clamp guard of its own: a render that folds part of the conversation as it appends the reply (a QuestionCard answered) can be laid out partway through its commit (removing a focused element forces it), pulling a reader at the end of the range upward before the thread reacts, so the glide starts from where the reader last scrolled to rather than the clamped spot; the FoldOnSend stories hold it. A shrinking child count reads as a conversation reset: the spacer collapses and the thread returns to the top. anchor={false} opts out entirely.
That choreography is the default flow, top-down, and the one every chat surface here uses unless a host chooses otherwise. flow="bottom-up" is the classic message thread for hosts that want it: no anchor and no spacer, the root becomes a flex column whose __content grows to fill it and pushes its turns to the end (justify-content: flex-end), so a short conversation sits just above the composer. A send glides to the end through the same scroll-behavior; content that grows while the reader is at the end (a streaming answer, a card, the follow-ups) keeps the newest line in view with an instant scroll, and scrolling up lets go until the reader returns to the end or sends again. Only an upward scroll lets go, so the send's own glide never counts as leaving.
The scrollbar is deliberately subtle: scrollbar-width: thin with scrollbar-color held transparent until a scroll event lands, then --color-divider over a transparent track while scrolling continues, fading back out after a settle delay (MOTION_SCROLL_SETTLE_MS, from the shared JS motion constants in src/tokens/motion.ts); scrollbar-gutter: stable reserves the gutter so a scrollbar popping in never reflows content sideways, and the component publishes the measured gutter width as --ds-chat-thread-gutter, which the content's right padding subtracts (clamped at zero) — so on classic-scrollbar platforms the column stays perfectly symmetric and flush with its surroundings, with the scrollbar tucked outside it at the far edge. Because the inline gutters live on the inner wrapper, the scrollbar rides the component's far edge rather than sitting inside the content column. The root is a role="region" named by ariaLabel (default "Conversation") with tabIndex={0}, so the scrollable area is keyboard-reachable and takes the house focus ring inset.
A scroll-to-bottom control appears whenever there is anything left to scroll to: a --radius-pill circle matching Composer's send button — the same 40px footprint (--icon-size-600 glyph plus the --padding-150 ring), pointing the other way — on the action-passive ramp painted over a --color-bg-container-primary surface with a --border-025 --color-bg-container-border edge and --shadow-floating, centred in a sticky zero-height slot pinned just above the bottom fade, floating over the transcript right on top of the composer. Clicking it glides to the bottom through the same CSS scroll-behavior the anchoring uses, and once nothing is left to scroll to it fades out and leaves the accessibility tree (aria-hidden plus tabIndex={-1}); jumpLabel names it for screen readers. The content column's bottom padding is --padding-1000, so the end of a long response rests 40px clear of the composer.
Composer
ds-composer — The chat input shell: an optional context note, a tray of queued files, an auto-growing textarea, a leading actions slot, and a trailing send button. The shell is the control — a --color-input-bg-primary surface behind a --border-025 --color-input-border-primary border, floating on --shadow-floating. It stacks two zones with different densities: a text zone padded --padding-400 --padding-500 --padding-200 (roomy above and beside the words, tighter toward the bar), and an action bar padded a uniform --padding-200 holding the leading actions slot, the trailingActions slot, and the send button. Bar icon actions take tertiary CircularButtons at the default size — borderless ghosts that only show a fill on hover — so every control in the bar shares the send button's exact 40px footprint and --icon-size-600 glyph, and the send button stays the only drawn circle. The radius is the one deliberate departure from the inputs-are---radius-300 rule: the corner is the component-local --ds-composer-radius, derived live as the send button's effective radius (its --radius-pill capped at half its size) plus the bar's --padding-200 ring plus the --border-025 width — 29px in the base theme, and automatically concentric under any re-themed radius (a crisp preset that squares the pill squares this corner with it); it is one component's geometry, deliberately not a step on the global radius scale. The textarea inside is borderless and outline-free so the shell carries the whole interaction ramp: --color-input-border-hover on hover, --color-input-border-selected on :focus-within, transitioning over --motion-duration-fast --motion-ease-standard. The shell is also the click target: clicking anywhere on it that is not a control focuses the textarea, with a cursor: text affordance everywhere except the action bar, which keeps the default cursor. Text sits on the full --font-paragraph-* set in --color-input-text-primary with --color-input-text-placeholder placeholders. The send button is a composed primary CircularButton (arrow_upward) — the one sanctioned use of the action colour in the chat set, because sending a message is a genuine primary CTA, exactly what that token is reserved for. It disables while there is nothing to send, which means no text and no ready file, and while any queued file is still uploading (a file on its own is a whole message, and onSubmit then fires with an empty string); while streaming it swaps to a stop button (stop, firing onStop) and both Enter and onSubmit go inert, so a person can never fire a submit into a running response. Enter without Shift submits; Shift+Enter breaks the line. The trimmed-empty guard means whitespace never sends. Native disabled takes the house 0.4 opacity with --color-input-bg-disabled and not-allowed cursors, and disables the send button with it. attachmentTileLabels carries the queued tiles' status lines, progress name and remove name (removeLabel(name)).
size (default or compact, the pair a table and its toolbar use) is the mini composer's density alone: compact takes the smaller text, height and button, and every other composer has the one size. inline is the shape: one row, with send on the text's line, at exactly size's --control-height-*, so it lines up with an Input or a toolbar of that density: the mini composer, for AI input inside other content such as a table, never for a chat. inline="chat" is the same row built out from a default-size send button instead of down from a control height, with --padding-150 around it, because in a chat the composer is the page's main input, not a field lined up with others. A context note inside the chat row takes a line of its own over the text and the send button, on the same --padding-150 ring and at the send button's height, so the corner stays concentric and a note alone never brings the footer's empty row. An inline shell does not float, its send button sits inside the height on a --padding-050 ring with the corner still derived from it, and maxRows defaults to 3 on the mini composer (12 on the chat row): typing grows the shell with the send button held in the bottom corner. A placeholder stays on one line and ends in an ellipsis: a browser will not ellipsize a textarea's own, so an aria-hidden copy, ds-composer__placeholder, draws the words while the attribute stays unpainted. It takes the plain input focus border unless aiGlow is set.
The textarea starts at one row and grows with its content up to maxRows (twelve by default, three on the mini composer), then scrolls internally: the cap is calc(var(--ds-composer-max-rows, 12) * var(--font-paragraph-line-height)) — the shell owns the block padding, so rows × line-height is the whole sum — with --ds-composer-max-rows set inline from the prop. Where the browser supports field-sizing: content the sizing is fully native; elsewhere a measurement effect sets the height from scrollHeight on every value change. The step between one height and the next is eased rather than snapped: a ResizeObserver publishes the textarea's measured height to the text zone as --ds-composer-text-height, and the zone states its height as that plus its own block padding, so growing a line and collapsing on send both run between two pixel values (auto has nothing to interpolate) over --motion-duration-instant --motion-ease-emphasized. At 75ms it lands under the threshold where a change reads as motion — the edge comes off the jump and nothing more. The zone clips (overflow: hidden) while the textarea sits at its new height already, so a new line is uncovered rather than pushed, and the action bar's buttons hold their geometry throughout — the bar translates with the shell, never resizing. files is the queue on this message, drawn as a tray of square AttachmentTiles above the textarea: one row that scrolls sideways instead of growing the shell. Composer owns intake and the tray, and the caller owns the list, mirroring FileInput's philosophy. Passing onFilesSelected arms three ways in at once: a built-in attach button at the head of the action bar (a tertiary CircularButton like every other bar action, opening a hidden picker that honours accept and multiple), files pasted into the textarea, and files dropped on the shell, which lights the selected border under a dropLabel hint while they hover. Composer hands the raw Files over and never stores one; onFileRemove reports a pressed remove button, after which focus moves to the tile that took the removed one's place, or to the textarea when the tray empties. A file drag the shell takes stops there, so a drop target around the composer never handles the same files twice. pasteThreshold is opt-in: with it and onPasteAsAttachment set, a pasted block at least that long becomes an attachment instead of flooding the field. A visually hidden role="status" line says what joined the queue, left it or failed, since a paste or a drop moves no focus (formatFileAnnouncement localises it). The tray sits --padding-300 in from the shell's edge and sets the tiles' radius to the shell radius less the border less that inset, so the tile corners stay concentric with the shell under any theme (16px in the base theme). The older free-form attachments slot still renders, after the tray, and is deprecated. actions is the leading footer slot for a model picker, after the attach button. context renders above both, at the very top of the shell: a full-width, non-interactive chip — filled with --color-chat-context-bg, the quietest tint per theme (the nearest neutral to each shell that still separates from it: --primitive-neutral-01 light, --primitive-neutral-09 dark) at --radius-pill, on ModelPicker's trigger geometry (the send button's 40px height, 0 var(--padding-400) inset) so the chip and the bar's pills read as one family, inset by the action bar's --padding-200 ring, --font-paragraph-sm-* in --color-text-tertiary (the tone of AttachmentTile's meta line — the note is ambient context, quieter than anything a person typed) — the "what the model is looking at" note a chat host pins over the message ("Looking at “Page name”"). contextIcon names an optional Material Symbol rendered at its left (--icon-size-500, aria-hidden, inheriting the chip's text colour — one tone for the whole note; none renders by default, the ai set's icon-free-unless-asked convention). The chip is one line: the text sits in its own span carrying text-overflow: ellipsis, so a note that outgrows the shell clips with an ellipsis and one that fits carries no trailing dots — an ellipsis is a truncation mark, not punctuation — while the icon never shrinks. The chip itself is min-width: 0, so in the one-row chat composer's flex row a long note ellipsizes inside the shell instead of widening the chip past it. onContextClear ends the chip in a compact tertiary CircularButton, named by contextClearLabel ("Clear context"), for a note that follows a selection and can go back to the page as a whole. Composer owns the chrome, the caller passes the text, and a click on it falls through the shell handler to focus the textarea like any other non-control surface. contextPlacement decides where the chip sits: inside, the default, is everything above; above lifts it out of the shell into its own full-width bar a --gap-200 gap above it (Composer then wraps the bar and the shell in a plain ds-composer-group column, so a host's own flex or grid gap can never land between them; className stays on the shell, and the bar carries the same click-to-focus handler), so the note reads as what the model can see rather than part of the message. The above placement also aligns the layout on one optical rail, calc(var(--padding-200) + var(--padding-400)) (24px, the bar's ring plus the pill inset): the text zone and attachments row take it as their inline padding through the shell's --context-above modifier, a ModelPicker trigger's label already lands on it, and the bar's own inline start adds the --border-025 it no longer sits inside. The bar's icon is pulled back by its glyph's side bearing so the ink rather than the box lands on the rail: 4/24 of the icon size by default, the bearing of the portrait document glyphs a context note usually carries, overridable through the --ds-composer-context-icon-bearing fallback hook. The inside placement keeps its own geometry unchanged. The site chat keeps it inside, ChatWidget's default. onSubmit reports the value but never clears it: the consumer owns the value and empties it after a successful submit. The textarea defaults to aria-label="Message" when the caller provides no accessible name; ref and unrecognised props land on the <textarea>, className on the shell.
The opt-in aiGlow prop dresses the focused shell in AiButton's exact signature — the system's "a model answers here" signal, for composers whose messages are answered by a model. On :focus-within the plain selected border goes transparent and two pseudo-elements play AiButton's entry: the blurred halo blooms out of the send-button corner (scale from a corner transform-origin, opacity from 0, --motion-duration-slower on --motion-ease-entrance) and the ring fades up a --motion-duration-fast beat behind it — transition-driven rather than AiButton's mount animation, because the composer wakes on a state change and must replay on every focus; the resting rules carry the way back, so losing focus collapses the halo quickly on --motion-duration-base. The pair: a conic-gradient ring in the --color-ai-gradient-* triple, masked to the same --border-050 band as AiButton's ring, its outer edge on the shell's own border box — one step up from the shell's --border-025 border, with the extra pixel growing inward so the geometry never shifts — and the same gradient blurred (14px, matched to AiButton) behind the opaque shell at 0.3 opacity so it reads as a halo around the edge. Both rotate in phase on AiButton's ambient cadence (the 21s three-uneven-turns loop), driven by a Composer-scoped registered @property angle so the component never depends on AiButton's stylesheet; browsers without @property render the ring static, and reduced motion keeps the ring but stops the turning. Off by default — the plain composer keeps --color-input-border-selected on focus, and a disabled composer keeps the pseudos dark even if a control inside it holds focus. aiGlow and streaming together define one more state, the working glow: while a response streams, the ring stays lit and keeps turning whether or not the field holds focus — the visible half of the streaming contract for glowing composers, carried by the same pseudo-elements with no new API. Either prop alone behaves as before, and the same reduced-motion rule stills the turn.
mentions arms the Mention pattern on the composer: one MentionSource per trigger character, @ for entities and / for skills. A trigger typed at the start of a word opens that source's MentionMenu from the trigger that called it: --gap-100 clear of the line the trigger is on, above that line by default and below it with mentionPlacement="bottom" (so on a message of several lines the menu lies over the text before or after the trigger, never a whole shell away from it), with the rows' first column of content lined up on the trigger character and the menu held inside the shell's width (the character's position is read from the overlay described below, since a textarea cannot report one); the list narrows as the word grows and closes when nothing matches. A trigger inside a word (the slash in a path, the at-sign in an address) opens nothing. Arrows move the highlight and wrap, skipping disabled items; Enter or Tab writes the choice over the typed word with a trailing space and never sends; Escape closes the menu for that word, takes the keypress so a dialog around the composer stays open, and the menu returns for the next trigger. The choice is written as a real edit (a range replacement followed by a genuine input event), so onChange fires with an ordinary event and form libraries see what a keystroke would have shown them; onMentionSelect reports the item and its trigger. A textarea cannot colour part of its own text, so while armed the words are drawn twice: the textarea keeps the caret, the selection and every keystroke with its text transparent and its caret in --color-input-text-primary, and an aria-hidden overlay beneath it draws the same string through MentionText, in the same type on the same rail, which is where a mention gets its tag. The overlay takes the textarea's measured height and client width (published as --ds-composer-text-height and --ds-composer-text-width, the second read again on every change because a scrollbar at the row cap narrows the measure without resizing the box) and follows its scroll position, so the two wrap and scroll as one. The textarea keeps its implicit textbox role and gains aria-autocomplete="list", with aria-controls and aria-activedescendant while the menu is open, and a visually hidden role="status" line says how many suggestions arrived, since the menu opens without moving focus (formatMentionAnnouncement localises it). The text rail itself is one shell-level property, --ds-composer-text-inset, which the text zone, the attachments row under contextPlacement="above" and the overlay all read.
AttachmentTile / AttachmentGroup / AttachmentViewer / AttachmentDropZone
The attachment family: how a file looks and behaves everywhere a conversation touches it. Four components share one stylesheet, Attachment.css, and one data shape, AttachmentItem (id, name, kind, size, status, and an optional preview). Two headless modules ship beside them: fileTypes reads a file's kind from its extension and mime type and formats sizes, with no React in it, and useAttachments owns a list of files, validates what joins it (accepted types, size, count, duplicates), runs an optional upload per file, and creates and revokes the object URLs behind picture previews. The library reads files in the browser and uploads nothing; sending a file anywhere is the host's upload function.
ds-attachment-tile — One file as a square, the same object in a composer's queue and on a sent message. The tile is --color-bg-container-primary behind a --border-025 --color-bg-container-border line at --radius-400, --padding-300 inside, with its content a centred stack: the type mark, the name, and one secondary line in --font-caption-* --color-text-tertiary. The name truncates in the middle, so the extension always shows: it is split into a head that ellipsizes and a tail (the end of the stem plus the extension) that never shrinks, which is pure layout with no measuring, so the tile renders from a Server Component. One visually hidden copy carries the full name to assistive technology. The upload spinner is named by progressLabel.
The type mark says what kind of file it is, and the family draws it one way. The four well-known formats (pdf, spreadsheet, document, presentation) get a file silhouette in their --color-file-* role with a short label lettered beneath it (PDF, XLS, CSV, DOC, PPT) in --color-text-secondary; every other file wears its extension as a neutral chip on --color-bg-container-secondary. The silhouette, the lettering and the fold are one inline SVG in a 32-unit square, so the lettering is drawing geometry that scales with the mark rather than a step on the type scale, and its fold is --color-scrim laid over the format colour. Every mark, and every glyph a state swaps it for, fills the same square (--icon-size-800, --icon-size-600 at size="compact") at the same seat, so a file changing state never moves or resizes anything. An item's typeLabel replaces the derived label wherever the family draws the mark (the tile, a lone picture's badge, the viewer, a composer's queue), to localise it or to name a format the derivation does not know.
status is the file's life. ready shows the stack with meta (a size, typically). uploading swaps the mark for a spinner, drawn to a state glyph's own circle and stroke and exposed as a progressbar with progress when known, over an "Uploading…" line. error swaps it for an alert in --color-status-error-icon, turns the border to --color-status-error-border and prints the error message or errorLabel. unavailable is a neutral block glyph over "Unavailable", for a file that has expired. Two things fill the whole square instead of the stack, and only while ready: previewSrc (a picture, or a first page the host rendered for a document, which keeps its format as a small badge over the corner; the library carries no document renderer), and excerpt, the first lines of a pasted block fading out under a "PASTED" badge.
onClick makes the body a <button> and onRemove adds a 24px circle straddling the top-right corner, siblings that never nest. The remove button shows on hover and focus, always on a failed tile, and always where hover does not exist; it hides by opacity, never visibility, so it stays in the tab order. A pressable tile answers hover by lifting its border to --color-text-tertiary and its surface to --color-bg-container-secondary, and a picture brightens and eases up a step. The tile's geometry routes through consumer hooks with fallbacks: --ds-attachment-tile-size (136px; 96px compact, raw edge lengths because a tile is a fixed object like an avatar, not a step on the spacing scale), --ds-attachment-tile-radius, and --ds-attachment-mark-size.
ds-attachment-group — The files on a sent message. One ready picture keeps its own aspect ratio, capped by --ds-attachment-single-max-width and --ds-attachment-single-max-height (320px each) at the bubble's --radius-600, with its extension badged over the corner; an item that states its width and height holds that capped box from the first frame, before the picture loads, so the thread never shifts when the pixels arrive. Anything else is a wrapping list of square tiles, --gap-200 apart, hugging the align edge (end for the sender's turns); four cells that cannot share a row sit two by two rather than wrapping three and one. Past max (default 4) the last cell becomes the next file dimmed under --color-scrim with a "+N" count, a button that expands the grid in place; a "Show fewer" button under the expanded grid folds it again. That is the system's disclosure trio (open / defaultOpen / onOpenChange), and focus follows whichever control replaced the one pressed. Pressing a file opens the built-in AttachmentViewer on it (viewer, on by default); an unavailable file stays on the message as a record and opens nothing. attachmentTileLabels and attachmentViewerLabels pass the tiles' and the viewer's words through.
ds-attachment-viewer — The preview dialog, a modal overlay on the shared behaviour layer (dismissal stack, focus scope, counted scroll lock). Its open state is controlled by design (see the Components intro): the host owns open and hears dismissals through onOpenChange, while the file on show follows the pair convention (activeId/defaultActiveId/onActiveChange). A header names the file with its type mark, its size and its position ("2 of 5"), with a Download button when onDownload is passed and a close button; a stage shows the file on a dotted canvas ground (--color-bg-container-border dots on --color-bg-page-primary, the texture of the docs site's dotted stages); a footer steps through the rest, and the arrow keys do the same. The viewer draws pictures itself, and a PDF or a text file that carries a fileUrl (the file's own address, kept apart from src, which is always an image) in place through a bare DocumentViewer at the stage's full size, with the empty card as its fallback where the browser will not embed it. Everything else is the host's through renderPreview, which also wins over the built-in views. ChatWidget passes renderAttachmentPreview and onAttachmentDownload through to the viewer its sent files open. A file with nothing to draw says "No preview for this file type" beside its mark, name and size; one that failed or expired says "Couldn’t load this file" and offers "Try again" when onRetry is passed. On a wide screen it is a centred dialog at --radius-300 under --shadow-modal; below 30rem it becomes a full-screen sheet with no margin or radius.
ds-attachment-drop-zone — A wrapper that makes a whole surface a drop target, so a file dragged anywhere over a conversation lands without aiming for the composer. While files hover, an overlay covers the surface, flush with it and sharing its corner radius: a solid --border-050 --color-input-border-selected edge (the active-input border, because the surface is the input about to take the files) with a soft glow of the same colour around it, over a frosted wash of --color-bg-container-primary that blurs the content behind, carrying an --icon-size-800 icon in the edge's colour (upload_file by default), the label ("Drop files to attach") and a hint line for accepted types and limits. The glow is the edge's own colour blurred, a colour effect like the Composer's AI ring and not an elevation shadow. The zone hears drags in the capture phase and keeps the ones carrying files to itself, so a target nested inside it, or a page-level one outside it, never handles the same drop twice; a drag of anything else passes through untouched. The overlay is decorative and aria-hidden: dropping is a pointer path, and the keyboard path is the composer's attach button.
DocumentChip
ds-document-chip — Deprecated: the earlier one-row file reference, superseded by AttachmentTile and kept working for existing consumers. A compact file reference: a two-line tile with a type icon, name, metadata line, upload progress, and an optional remove button, for documents attached to chat messages or queued above a composer. The tile is --color-bg-container-primary behind a --border-025 border at --radius-300, with the border colour routed through a local --ds-document-chip-border custom property so the error state repoints one variable to --color-status-error-border instead of restyling parts. A module-level map picks the Material Symbol for each of the eight fileType values (pdf, doc, sheet, slide, image, code, archive, generic) at --icon-size-600, overridable via icon; the name truncates with an ellipsis at a consumer-overridable --ds-document-chip-max-width, in --font-paragraph-sm-emphasis-*. meta is free text in --font-paragraph-sm-* --color-text-tertiary ("1.2 MB", "12 pages"), so callers keep their own formatting. progress swaps the metadata line for a composed compact ProgressBar with its percentage label; error replaces it with a --color-status-error-text message and colours the border and icon with the error pair. size="compact" tightens the padding and drops to one line — name only — for dense composer rows. With onClick the chip is one target: the body button's hit area stretches over the tile, which takes the --color-bg-container-secondary hover fill and the focus ring, and the remove button stays a target of its own above it.
Passing onClick turns the body (icon + text) into a <button> inside the root <div>, so click and remove coexist without nesting interactive controls; the remove button carries removeLabel as its accessible name and every icon is aria-hidden. Chip stays the one-line --radius-pill pill for attributes and filters; FileInput stays the form control that owns selection — DocumentChip only references a file the host already holds.
InterruptCard
ds-interrupt-card — A human-in-the-loop checkpoint: a question from the agent with option buttons for the person to decide ("Allow file edit?" — allow once, always allow, deny). It shares ToolCall's skeleton and footer: the same neutral --color-chat-card-bg container at the chat furniture's shared --radius-600 shell behind a --border-025 --color-bg-container-border, and an options footer with ToolCall's exact actions-footer values (the card's own background, flex-start at --gap-200, --padding-200/--padding-500/--padding-400, separated from the body by whitespace alone — no rule), so a decision on a card and an approval on a call read as the same row. The card is deliberately single-tone in both states — no status colour on icon or border; the question and its buttons are the signal, and a visually hidden status announcement carries the waiting state. The icon sits in --color-icon-secondary via the local --ds-interrupt-card-color. Once value is set the decision is made: the buttons give way to the chosen one's read-out on the same footer padding, worded as the decision made rather than the button pressed (the option's own answeredLabel, "Denied" for "Deny", falling back to its label; the card's answeredLabel overrides both), a one-off drawn at a compact Button's height, pill, padding, type and --icon-size-500 glyph so nothing jumps, filled in the --color-status-*-bg and -text pair of the status the option records, behind check (close for error): its answeredVariant, else positive for a primary option, error for a destructive one and neutral for the rest, so an approval reads green and a refusal marked error reads red (the --answered modifier stays on the root as a consumer styling hook). The header stacks a --font-title-body-* title (the chat cards share one title tier, the type scale's card-title role, because a heading tier inside a reply would outrank the reply's own text) over a --font-paragraph-sm-* --color-text-secondary description, with an optional Material Symbol at --icon-size-500 trailing at the header's end, so the text starts on the card's inset edge — none renders by default, keeping the ai cards icon-free unless a caller opts in. The waiting announcement is waitingLabel. bare strips the shell (fill, border, radius) when another panel supplies the surface, the charts' bare convention; AiButton's request panel is the host it exists for.
InterruptCard is for a run that stops on a tool call until a person decides; a choice that simply continues the conversation (permission asked in a reply, a retry, applying a proposed change) is a QuestionCard instead, whose answer is sent as the next message (see QuestionCard). Options are structured data ({ value, label, variant, tone }), not a free slot, because the options are the component — each renders as a compact Button with the option's emphasis and tone (in a row of three or more the last, the way out, takes ActionCard's Dismiss treatment, secondary in the neutral tone, unless it names a tone of its own; primary for the recommended choice, tone: 'destructive' for a dangerous approval; the legacy variant: 'danger' maps to secondary × destructive) firing onValueChange with its value. The card is fully controlled and holds no state. Freeform "tell it what to do instead" input is deliberately out of scope in v1; compose richer detail via children, whose intended pairing is <InterruptCard><ToolCall status="pending" /></InterruptCard> — the question above, the exact call it covers below. The root is role="group" named by the title, with a visually hidden role="status" "Waiting for your decision" while unanswered.
QuestionCard
ds-question-card — An agent's question with answers it can list, drawn under the reply that asked it: one question or a short set (the chat widget caps a set at MAX_QUESTIONS), each single (one option) or multi (several, with an optional min and max), and an optional last "Something else" row that opens a text field in its place. The open field is a row like the others: solid with its mark on while it holds an answer, the mark off again when the text is cleared; a lone one-answer question sends it from a compact CircularButton in the mark's place. QuestionCard is the default for any choice that keeps a conversation going, including permission, a retry or applying a proposed change: the answer is sent as the next message, so the exchange reads as one continuous thread. InterruptCard is the hard stop on a tool call: the run is paused on that card, with the call it covers inspectable inside it, and the decision is made on the card rather than in the thread. The two stay separate because their shapes differ: buttons in a footer against option rows, a step count and multi-select. The hidden chosen marker is labels.chosen.
The card has no shell. It sits inside the assistant's message under the prose, so the rows carry the structure: each a full-width control on Input's surface (--color-input-bg-primary at --radius-300) with --padding-300/--padding-500 (the chat cards' 20px text inset, so a row's label lines up with the cards around it), drawn with a --border-025 dashed --color-bg-container-border line, the suggestion chip's hairline colour, while it is an answer on offer. Hover turns the line solid in --color-input-border-hover; a pick turns it solid in --color-text-primary, doubled by an inset ring of the same width so the row does not grow. Every row carries the real control's indicator at its end, which says what kind of question it is before anything is touched: RadioIndicator for one answer, CheckboxIndicator for several, each lit on hover through the row's ds-control-host class and showing the control's checked state when picked. A --font-paragraph-sm-* --color-text-tertiary line above the options says it in words too (labels.chooseOne, "Choose one"; labels.chooseMany, "Choose one or more", or the count min and max set), and names the group through aria-describedby; in a set it tucks up under the heading. The label is --font-paragraph-*, an optional description --font-paragraph-sm-* in --color-text-tertiary. The row's border, in the neutral ink, is what outlives the moment as the record of a choice; the indicator shows the control's checked state the way the form control would. Rows rise in with the suggestion row's staggered pop.
One answer to one question is a tap: the option is the answer. A multi question collects its picks until Send, which waits for min, while a reached max disables the rows still off. A set asks one question at a time under a --font-paragraph-emphasis-* heading with a --color-text-tertiary "2 of 3" count; a tap marks a one-answer question's pick and Next moves on, so a slip can be changed before it is passed, the last question sends with Send, Back steps back, and focus moves to the new question's first option at every step. The digits 1 to 9 pick by position. The footer (tertiary Back and Skip, then Next as secondary or Send as primary, all compact Buttons) appears only when there is something to press. Skip shows only when the host passes onSkip.
Answered (values set) the card stops taking input and keeps what was offered: one question keeps every row, "Something else" included, the picks marked and the rest at the dimmed convention's 0.4; a set folds to a QuestionSummary. An empty values records a skip and disabled records that the moment passed: one question dims every row under a --color-text-tertiary line saying which, and a set lists every question it asked with that word in --color-text-tertiary where the answer would be, never just the first, which would read as if one question had been asked. ds-question-summary — QuestionSummary, the second export of the QuestionCard module, is the record of a set: each prompt in --font-paragraph-sm-* over its answers in --font-paragraph-* (several joined with commas), --gap-050 within a row and --gap-300 between rows, and missingLabel in --color-text-tertiary where a question has no answer. The card variant (QuestionCard's) frames it as a card at --radius-300 on --color-bg-container-primary behind a --border-025 --color-bg-container-border hairline with --padding-300/--padding-400 and sets the prompt in --color-text-tertiary; plain drops the frame and takes its ink from the surface it sits on, with the prompt in --color-text-secondary so it stays legible on a bubble's fill. One export for both sides of the conversation, so the question's record and the message that answered it are the same pattern.
Fully controlled: onValuesChange reports every question's answer once the last is given, as { questionId, values, other? } with values in the options' order.
Mention
ds-mention — A reference written into a sentence, and the menu that reaches for it: @ for an entity (a person, a file), / for a skill. One registry entry, three exports from one module, because they are one pattern: Mention is the inline tag, MentionMenu is the list a trigger opens, and MentionText draws a plain string with every mention in it as a tag. The pure helpers behind them (getMentionQuery, filterMentionItems, splitMentions, insertMention) live in a React-free sibling module with a package subpath of its own (./components/Mention/mentions), so a Server Component or a route handler can run them without crossing the components' client boundary; the component module re-exports them too.
The pattern stores nothing beside the text. A mention is recognised wherever a trigger character is followed by the exact label of one of that source's items, standing as its own word, and it stops being one the moment the name is edited. That is what lets the value stay a plain string through typing, sending, storing and rendering: the composer and the sent message both derive their tags from the same text and the same MentionSource list.
The tag is drawn so that it can be laid exactly over the text of a textarea. It takes the surrounding type whole and adds the theme's action colour in its text form, --color-action-primary-text-tertiary, the tone a tertiary button writes its label in: the name is set in it with two parts in five of --color-text-primary mixed in, over a faint wash of the unmixed tone (10% of it over whatever surface is beneath) at --radius-100, each routed through a consumer hook (--ds-mention-color, --ds-mention-bg). A mention is the one word in a sentence that was chosen from a menu and points at something, which is why it may wear the colour reserved for what can be acted on; it uses the text token and never the action fill. The mix is there because a name in running text is smaller and lighter than the button label the token was tuned for: unmixed, it fell under 3:1 against the page in the themes whose action colour sits nearest their surface, and at this ratio it holds 4.5:1 in every shipped theme and both modes. A fixed hue was tried first (the info family's saturated tone) and blended into the themes built near it, where the action colour moves with the theme. The fill backs it up, because in a theme whose action colour is a neutral the colour alone says little. Nothing on the tag may move a glyph off its advance, so it carries no weight, no letter spacing and no net width: the fill reaches --padding-050 past the glyphs on each side through padding that an equal negative margin pays back (--ds-mention-bleed), and box-decoration-break: clone keeps the fill and corners on both lines of a name that wraps. The trigger character sits a step quieter than the name, at reduced opacity, since it is punctuation and the name is the word.
ds-mention-menu — The floating-surface recipe the chat set's menus share: --color-bg-page-primary (opaque, because the container fill is translucent in dark mode) behind a --border-025 --color-bg-container-border line at --radius-300 under --shadow-floating, fading in over --motion-duration-fast --motion-ease-entrance. Rows are one line on the --font-paragraph-sm-* set: an optional leading mark (a Material Symbol at --icon-size-500 in --color-icon-secondary, or any node), the label, and a description in --color-text-tertiary that takes the remaining width and truncates. Rows sit --gap-050 apart inside a --padding-100 inset at --radius-200, concentric with the panel. The list shows --ds-mention-menu-max-rows rows (default 8) and half of the next, so a longer list is cut mid-row and reads as scrollable; --ds-mention-menu-width sets the width (320px by default, never wider than its container).
The menu never takes focus. The caret stays in the field being typed in, which owns the arrow keys and points aria-activedescendant at the highlighted row (mentionOptionId builds the id, mentionListboxId the id for aria-controls); a press on the menu is kept from blurring that field. There is one highlight, shared by the pointer and the keyboard: the pointer moves it (on move, not on enter, so a list scrolling under a resting pointer never steals it) and it is drawn in --color-action-passive-bg-hover, DropdownMenu's focused wash, a translucent neutral that differs from the panel in every theme where an opaque surface step may not. The highlighted row is kept in view by moving the list alone, never by scrolling ancestors. While a query narrows the list, each label steps back to --color-text-secondary and the typed part holds --color-text-primary in the emphasis weight, so the cue survives a theme that sets both paragraph weights the same. A disabled item stays listed at the house 0.4 opacity and cannot be chosen. The list is a role="listbox" named by label and is itself a tab stop with the house focus ring inset, because a region that can scroll has to be reachable from the keyboard on its own terms; in normal use the field takes Tab as "choose", so the stop is only met by someone who went looking for it. With no items the menu renders nothing unless emptyText is passed.
MessageActions
ds-message-actions — The icon-button row for message-level actions: copy, retry, feedback. Built to slot into ChatMessage's actions prop, which reveals it on hover and keyboard focus, so the row's own job is just the buttons. Each item is a ghost button — no border, transparent at rest, --radius-200 with var(--padding-100) padding around a Material Symbol at --icon-size-500 in --color-icon-primary — hovering onto --color-action-passive-bg-hover and --color-text-primary over --motion-duration-fast --motion-ease-standard. The row itself is a flex line at --gap-050. An active item (the chosen feedback thumb) holds --color-action-passive-bg-active; focus takes the house ring inset (outline-offset: -2px), disabled the house 0.4 opacity. A destructive item rests like its neighbours, keeping the row quiet, and on hover takes --color-action-destructive-text-secondary over the --color-status-error-bg tint, as a destructive menu item does. These stay ghost buttons rather than CircularButtons because a resting surface per icon would give a quiet utility row four competing pills.
Every button carries its item's label as aria-label, and by default a composed Tooltip shows the same label on hover and focus (showTooltips={false} drops the tooltip, never the name). aria-pressed renders only when an item sets active — a toggle-like action announces its state, a plain command like copy stays a plain button. The row is stateless beyond hover: copy feedback is the consumer swapping the item's icon to check (and its label to match) for a moment, so the component never owns a timer or a clipboard call — onActionClick fires with the item's stable id and the consumer decides everything else.
MessageCard
ds-message-card — A structured rich-content card embedded in a chat message: media, title, body, and an actions row, for link previews, search results, and booking-style rich responses inside an assistant turn or bubble. Card is the navigation tile you click to go somewhere; MessageCard is content furniture inside a conversation — but the two share one card geometry, and MessageCard's --radius-600 shell matches the chat bubbles around it. The container is --color-chat-card-bg behind a --border-025 --color-bg-container-border border. The optional media slot is inset, not full-bleed: --padding-300 (12px) of card around a --radius-300 (12px) slot keeps its corners concentric with the card's 24px shell — the exact geometry of Card's case-study cover, and the same rule the Composer follows (images inside render block at full width). The slot carries its own --color-bg-container-secondary surface, a step in from the card's crisp fill, so drawn media — an SVG, an illustration — reads as the same image-shaped panel a photo would, on a ground that recedes rather than tints. A Chart placed in the slot sheds its own panel chrome (background, border, radius, most of its padding) and the slot takes --color-bg-container-primary in its place, the surface the chart's own panel would carry: the card is already the panel, and the secondary tone is a mid grey in dark mode that read as a block behind a plot. The body stacks at --gap-200 inside --padding-400 top / --padding-500 side / --padding-200 bottom padding (--padding-400 bottom when no actions follow), so the media-to-body distance equals the body-to-actions distance and the copy sits centred between image and CTA row. It opens with a header unit — a --font-title-body-* title with an optional Material Symbol at --icon-size-500 --color-icon-primary trailing at the row's end (the chat cards' one title tier, see InterruptCard), with the free-text meta line ("transit.example", a date) in --font-paragraph-sm-* --color-text-tertiary tucked --gap-200 beneath it as the title's subline — then a description in --color-text-secondary, then any children — a Prose block, a DocumentChip row. The card claims no width of its own: the bubble or turn constrains it, with --ds-message-card-max-width as the consumer-overridable cap.
The actions footer mirrors ToolCall's actions footer exactly — the card's own background, flex-start at --gap-200, --padding-200/--padding-500/--padding-400, separated from the body by whitespace alone rather than a rule — so approvals on a tool call and link actions on a card read as the same row across the ai components.
ModelPicker
ds-model-picker — The model selector for a chat surface, built for Composer's actions slot but freestanding anywhere. The trigger is a quiet --radius-pill pill on --color-action-passive-bg (--font-paragraph-sm-emphasis-* in --color-text-secondary, rotating expand_more chevron) — deliberately chrome, not a call to action, so the send button keeps the only action colour in the composer. It is the row height (--control-height-*, CircularButton's too) at size default or compact, with only horizontal padding, so the pill and the composer's round icon buttons read as one row of equals. The panel is the floating-surface recipe (--color-bg-page-primary — the opaque page fill the floating menus and panels share, because the container fill is semi-transparent in dark mode and would show text through — hairline border, --radius-300, --shadow-floating, min-width 260px) placed by the shared anchored-position behaviour, placement naming the side it prefers (top for a composer pinned to the bottom of the screen); it flips on its own when that side has no room. The model list and the effort group are named by listLabel and effortLabel. The trigger carries role="combobox" (a select-only combobox, Dropdown's pattern), which is what makes its aria-activedescendant valid while the list is open. The empty trigger text, and the default name, is placeholder.
Models list as a role="listbox" of rows — --font-paragraph-sm-emphasis-* name with an optional --color-status-info-bg badge pill, a tertiary description line, and the shared ChosenMark on the selected row — with Dropdown's keyboard model (arrows move, Home/End jump to the ends, Enter selects, Escape closes, outside click dismisses) and aria-activedescendant tracking the highlighted row. A disabled entry stays visible in the list rather than vanishing: it takes the house disabled treatment (opacity 0.4, cursor: not-allowed) with aria-disabled on the row, clicks are suppressed, and arrows and Home/End skip over it — which is what lets a host grey a model out while keeping the lineup readable (the site chat's budget lock leans on exactly this). When an effort value is provided (effort/defaultEffort/onEffortChange), an effort row appears below a --color-divider rule: an "Effort" label beside a compact neutral SegmentedControl, the library's own mutually exclusive set (one tab stop, arrows move focus and selection together), named by the label through aria-labelledby. Effort levels default to Low/Medium/High and are overridable via effortOptions. Model and effort selection both follow the controlled/uncontrolled pair convention.
PromptSuggestions
ds-prompt-suggestions — Tappable prompt suggestions to start or steer a conversation. In the chip layouts each suggestion renders as a clickable Chip, so one component owns the pill look — the row only arranges them and never restyles the pills. size picks which Chip size to ask for, one step above Chip's own scale because a conversation starter is something to tap rather than metadata about something else: default renders large chips at the body-paragraph scale, so a suggestion reads at the same weight as the message it will become, with a --gap-300 gutter; compact renders Chip's default 32px pill at --gap-200, for rows sitting alongside a live conversation. layout picks the arrangement — three chip rows, described here, and two block layouts, described below — named on the element rather than inferred so each owns its own rules. scroll (the default) is a single line (overflow-x: auto) whose edges fade out through a pure-CSS mask-image gradient with --gap-500 stops: the fade is the overflow hint, with no scroll listeners and no JS measurement. wrap trades that for flex-wrap across multiple lines, the empty-state hero placement where everything is visible and there is no overflow to hint at. stack gives each suggestion its own line for narrow columns, where a wrapped row breaks wherever the labels run out of room and the ragged edge reads as an accident; the pills still hug their labels (align-items: flex-start), since stretching them to a shared width would make a set of prompts look like a set of buttons. padding-block: var(--padding-050) keeps chip focus rings clear of the scroll clip. The boolean wrap prop is a deprecated alias for layout="wrap", ignored when layout is set. Two further layouts leave the chip behind for an empty-state home screen. tiles is an even row of quiet blocks, each an icon over the label and a description; cards gives each suggestion a cover holding its icon, with the label and description beneath. Both draw their own button on the Widget surface (--color-bg-container-primary-semi over a 24px blur, a --border-025 hairline, --radius-600) and keep the row's shared behaviour: list semantics, the pending shimmer in the block's own shell, the staggered entrance, and the press-and-spring acknowledgment. The two answer the pointer differently on purpose: a tile stays put and firms up (the fill to --color-bg-container-primary, the hairline to --color-text-tertiary), while a card lifts with Card's own hover (the same 4px rise and the same sanctioned hover-lift shadow, restated under ds-allow(color)), so a prompt card and a navigation tile behave alike. A card's cover is a top-to-bottom wash of one --color-pixel-ink-* token with the icon standing in it in the same ink, the inks cycled by position so a row is told apart by colour as well as title. The grid folds on its own width: the column floor is three cover icons for a tile and four for a card, so four tiles or three cards share a row on a conversation column and drop to two columns and one on a phone. value is fully controlled by design, with no defaultValue: it records a choice the host acted on, and the row never latches one itself. The hidden chosen marker is chosenLabel.
The root is role="list" (named by ariaLabel, default "Suggested prompts") with each chip in a role="listitem" wrapper, so a screen reader announces how many suggestions there are before reading any. The chips themselves are real buttons — Chip's clickable mode — which also keeps the scrollable region keyboard-reachable. Tapping one fires onValueChange with the suggestion's stable id, never its display text.
pending swaps the suggestions for placeholder pills (ds-prompt-suggestions__skeleton, pendingCount of them, default 3): the chip's real shell — its --color-bg-page-primary surface, --border-025 --color-bg-container-border border, and the current size's padding and computed height at --radius-pill — holding a text-height shimmer bar (ds-prompt-suggestions__skeleton-bar, ThreadPanel's 0.75em --color-bg-container-secondary bar with Skeleton's sweep on --motion-duration-loop-shimmer) where the label will be, so what reads as loading is the label generating inside a finished pill, and the row is exactly as tall pending as loaded. Shell widths cycle through three em values so the set reads as questions of different lengths rather than identical pills. The placeholders are aria-hidden and the root reports aria-busy, so assistive technology hears a busy list rather than three empty items — the aria-busy is load-bearing, exempting the momentarily item-less list from the required-children rule. The site chat uses the state both ways: honestly under a finished answer while its follow-up questions are generated, and staged for MOTION_SUGGESTIONS_THINK_MS when the welcome screen's pre-written starters swap on navigation — the summary panel's think beat applied to chips.
Items pop in one after another on mount — a --motion-duration-slow rise-and-settle (translateY + slight scale) on --motion-ease-spring, staggered one --motion-duration-instant per pill and capped at the fifth so a long scrolling row does not keep its far end waiting, backwards-filled so a delayed pill waits unseen for its turn. The pending placeholders and the chips that replace them are both items, so each set lands one-two-three; every timing is a token, so the reduced-motion collapse stills the entrance with everything else. entrance={false} suppresses the pop-in for a set that has already landed — a host re-showing suggestions the visitor watched arrive (the site chat reopening over the same page's starters, or over follow-ups from earlier answers) renders them standing rather than arriving twice.
Tapping a chip plays an acknowledgment beat before the host hears about it: the chosen chip presses in and springs back (--motion-duration-slow on --motion-ease-spring — the overshoot is the bounce) while its siblings dim to the system's 0.4 convention on a --motion-duration-base opacity transition, and onValueChange fires MOTION_EXIT_SYNC_MS after the tap — so a host that unmounts the row on selection (the chat sending the suggestion) still lets the choice be seen. One choice at a time: taps are ignored while a beat is in flight, which also guards a double-tap firing twice; the chosen state clears when the pulse ends, so a row that persists without value returns to normal and stays tappable. A row that should keep the choice once used, such as the chat's follow-ups under an earlier answer, sets value (below).
value turns a row that stays on screen after use into the record of the choice (--kept): the taken suggestion outlined solid in --color-text-primary, doubled by an inset ring of the same width so it does not grow, and every other one faded to 0.4 behind a dashed --color-bg-container-border line; every one drops its fill, so the record reads as an outline; no chip or block answers the pointer any more, and a visually hidden "(chosen)" names the taken one. It is QuestionCard's answered treatment less the indicator and the input surface, so a chip keeps its shape.
Prose
ds-prose — Token-styled typography for rendered markdown and rich agent output. The package ships no markdown renderer: consumers render markdown with whatever library they already use and wrap the output in Prose, which styles the descendant elements through scoped selectors. Body text sits on the --font-paragraph-* scale in --color-text-primary with --gap-400 between blocks, edges trimmed (first-child/last-child margins zeroed) so it slots into bubbles and cards cleanly. Headings step down the token tiers — --font-heading-1-* through --font-heading-3-*, then --font-title-body-* for h4 — each applying its full five-property set, so headings take the theme's tier weights without Prose restating them. Links use --color-action-primary-text-tertiary with an underline; strong takes --font-paragraph-emphasis-weight; lists indent by --padding-400 with --gap-100 between items.
Inline code and pre both use --font-family-code, scaled by --font-code-aspect like every code surface: inline code sits on --color-bg-container-secondary at --radius-050 with --padding-050 horizontal padding, one size step down; pre is a --color-bg-container-primary panel with a --border-025 --color-bg-container-border border at --radius-300, --padding-400, scrolling horizontally rather than wrapping, and pre code drops the inline chrome. Blockquotes take a --border-050 --color-divider left rail with --padding-400 inset in --color-text-secondary, mirroring Reasoning's trace rail. Tables are the markdown tier of the table family (Table surfaces, above), with the blueprints' tables' metrics exactly: a --color-table-border outline at --radius-300 over --color-table-body-bg (separate borders, since a collapsed table cannot round its corners), --font-paragraph-sm-emphasis-* headers on --color-table-header-bg, --font-paragraph-sm-* secondary body text top-aligned, --padding-300 by --padding-400 cells, and --color-table-divider row rules. tableSize="compact" tightens the cells to the library Table's compact step (--padding-150 by --padding-300) for long or dense tables; the site's MarkdownTable takes the same size. They are their own scroll container: display: block with width: max-content, max-width: 100% and overflow-x: auto, so a table wider than its container scrolls sideways instead of being clipped. Prose styles markup it does not render and so cannot add a wrapping element, and the price of making the table element the scroller is that it sizes to its content rather than filling the container, since the anonymous table box inside a block table cannot be stretched. Cells also set overflow-wrap: normal, which holds each column's minimum width at its longest word no matter what the surrounding context does; without it an ancestor's overflow-wrap: anywhere (ChatMessage sets it so a long URL cannot widen a bubble) inherits in, collapses every column to a single character, and auto table layout squeezes headings into stacks of letters instead of letting the table reach its natural width. Consumers who want the scroll reachable by keyboard alone should put tabindex="0" on the table, which is markup only the consumer can supply; role="region" belongs on a wrapping element if the scroller also needs an accessible name, never on the table itself, where it would replace the table semantics. hr is the same hairline; images cap at max-width: 100% under --radius-300.
size="sm" moves the body scale to --font-paragraph-sm-* for dense chat contexts while headings keep their tiers. Prose adds no roles or behaviour of its own — the semantics are the consumer's markup — and its pre is the plain fallback: CodeBlock remains the richer choice for standalone code. A renderer may also map fenced code to CodeBlock inside Prose (the site chat does, for the copy button): a .ds-code-block takes the same --gap-400 block rhythm, and the plain-pre panel and inline-code chip step aside so CodeBlock's own border, header and padding are the only chrome.
Reasoning
ds-reasoning — A model's thinking, disclosed behind a one-line summary. The trigger is a borderless --font-paragraph-* button in --color-text-secondary (stepping to --color-text-primary on hover) with an expand_more chevron after the summary — trailing, like ToolCall's, so the summary line starts at the same left edge as everything around it — rotated −90° to point at the collapsed trace, turning back to pointing down as the panel opens, the same closed/open convention CodeBlock's chevron uses. summaryOnly drops the chevron, the trigger, and the panel, leaving the summary line alone — for a model that reports what it is doing but produces no trace to read, where a disclosure would open onto nothing. The line keeps the trigger's own geometry, so a trace arriving mid-response promotes it to a real disclosure without moving anything. The panel collapses with the grid-template-rows: 0fr → 1fr technique over --motion-duration-slow, and toggles visibility alongside it so collapsed content leaves the accessibility tree. The trace sits on a --border-050 --color-divider rail in --color-text-secondary — the rail dissolves to the transparent container colour at its foot, the same end-of-line treatment as Timeline's last stem — its geometry derived from an AgentStatus summary: the rail runs down the centre of the matrix (half an --icon-size-600, corrected by half the rail width) and the trace text starts exactly where the status label's first letter starts (--icon-size-600 + --gap-300; the compact size derives the same sums from --icon-size-500 + --gap-200). A plain text summary shares the same geometry, so swapping a live status for the thought-for line never shifts the trace — quiet enough never to compete with the answer beside it. The text scale pairs with ChatMessage: the default size matches default message text, and size="compact" drops trigger and trace to --font-paragraph-sm-* to match compact message text. The summary's three states are thinkingLabel ("Thinking"), durationLabel(seconds) ("Thought for Ns") and doneLabel ("Thought process"); label overrides all three.
streaming opens the panel and shimmers the summary with the same treatment AgentStatus uses, so "the model is working" looks the same wherever it appears; when the stream ends the panel collapses to Thought for {duration}s. That auto-collapse yields to the reader — once someone has toggled the panel themselves, the stream ending no longer moves it. Only the summary is a role="status" live region: a trace announced token by token floods a screen reader, so the body stays ordinary expandable content and the announcement covers the boundaries.
The summary slot replaces the line with custom content — designed for an AgentStatus, so the live indicator and the trace disclosure work as a pair: AgentStatus-with-trace while the agent runs, the quiet thought-for line above the finished response. A node summary owns its own appearance and announcement, so Reasoning's text shimmer and live region stand down (AgentStatus shimmers and announces itself). Dropping the summary prop animates the handoff itself: the departing node fades out in place while the text summary slides left from the label position into the flush edge, the distance derived from --ds-reasoning-align (matrix + gap, the same sum the rail geometry uses) — consumers flip streaming off and drop the prop; the transition is the component's job.
Inside the panel, a list is the trace's node list: one thinking step per item, no bullets (the rail is the marker), --gap-500 between nodes — the same beat the panel opens with — and new nodes rise in as they mount, so a trace that builds while an agent works animates without any consumer wiring.
SourceChip
ds-source-chip — A numbered citation pill linking a claim to its source: a borderless --radius-pill pill on --color-bg-container-secondary holding a leading slot and, --gap-200 after it, a source title in --font-paragraph-sm-* --color-text-secondary, the title truncating with an ellipsis at 24ch. The leading slot is never empty and is always one of three things, each in an --icon-size-500 box: the citation number (index) — a --font-paragraph-sm-emphasis-* tabular-nums numeral in --color-text-on-inverse, centred in a solid --color-bg-container-inverse circle (the digit itself, not its line box: the numeral's box is trimmed to cap height and baseline with text-box, since a line box's unused descender room would set a lining figure high), so it reads dark on light in dark mode and light on dark in light mode and carries the weight a logo's full-colour disc does; else the source's logo — an image URL or a custom element, cropped to the same circle over a --color-bg-container-tertiary ground, so a transparent favicon still reads as a badge; else a glyph in --color-icon-primary, the language globe unless icon names another Material Symbol. The slot is tucked into the pill's start: the chip's padding is --padding-100 above, below and before the slot and --padding-300 after the title, and the circle's radius is the pill's less that inset, so the two are concentric and a squared theme squares the badge with the pill. With an index set, the logo still leads the preview's top line.
The root is an <a> when href is set, a <button> when the chip has a preview but nowhere to link, and a plain <span> otherwise. The anchor and the button are the interactive forms, and they take Chip's passive wash: --color-action-passive-bg-hover on hover and --color-action-passive-bg-active while pressed, the text stepping to --color-text-primary, over --motion-duration-fast --motion-ease-standard. The numeral's inverse disc holds through every state; the wash moves behind it. text-decoration: none throughout — the pill shape is the affordance — and focus draws the house --color-action-primary-bg ring. The plain span keeps cursor: default and no hover. It renders inline after a sentence or in a wrapping sources row under an assistant answer, pairing with ChatMessage's footer slot.
An excerpt gives the chip a source preview: a panel on the floating surface (--color-bg-page-primary, --border-025 --color-bg-container-border hairline, --shadow-floating, --radius-300, capped at 320px) holding a top line in --color-text-tertiary (the logo when there is one, in the leading slot's circle and decorative because source carries the name; then source, then meta after a middle dot, and an arrow_outward at --icon-size-500 when the chip is a link), the full untruncated title in --font-paragraph-sm-emphasis-*, and the excerpt in --font-paragraph-sm-* --color-text-secondary, clamped to four lines. It opens like HoverCard: hover or focus with the shared MOTION_HOVER_SHOW_DELAY_MS/MOTION_HOVER_HIDE_DELAY_MS timings, staying open while the pointer is inside the panel; a press opens it at once, which is how touch reaches it. Escape or a press outside dismisses it, and only one preview is ever open: a chip held open by keyboard focus gives way when the pointer opens its neighbour. While it shows, the chip holds its hover step. The panel is role="tooltip" and the chip points at it with aria-describedby, so the excerpt is read with the title whether or not the panel is showing. Placement is measured, since a chat's message list clips its overflow: above the chip and flush with its leading edge, flipped below when the space above is short, and shifted sideways until it sits inside the viewport and every clipping ancestor. Hidden state is display: none, transitioned discretely, as HoverCard's is.
SourceTrail
ds-source-trail — The research trail under an agent's answer: the sources it opened, in order, each row a SourceChip with a status indicator and an optional contribution note. The shell is ToolCall's exactly — --color-chat-card-bg behind a --border-025 --color-bg-container-border at the chat furniture's shared --radius-600, header and list inset --padding-500 from the sides, the panel on the same 0fr → 1fr grid collapse with visibility handling — so a collapsed trail reads as one more row in a run of tool calls rather than a different species. The header is a disclosure button carrying a computed count ("Reading 3 sources" while anything is pending or active or while streaming, "Read 3 sources" once settled; title overrides it) in --font-paragraph-sm-emphasis-*, a trailing indicator before the chevron (a tone="inherit" Spinner while unsettled, travel_explore at --icon-size-500 once done), and ToolCall's rotating expand_more chevron. Each row's visually hidden status reads from statusLabels. The computed header comes from readingLabel(count) ("Reading N sources") while any visit is unsettled or the trail is streaming, and readLabel(count) ("Read N sources") once every visit has settled; a title replaces both.
Rows are an <ol>: per-item status follows AgentPlan's vocabulary — circle for pending (the whole row at 0.6 opacity: not opened yet), a Spinner for active in --color-status-info-icon, check_circle for done in --color-status-positive-icon — each with visually hidden status text announced beside the title. The chip is a real SourceChip: href makes it a link (opened in a new tab with rel="noopener noreferrer"), icon fills its leading slot (the globe when unset), and the detail note truncates in --font-paragraph-sm-* --color-text-tertiary. While streaming, an appended row eases in over --motion-duration-slow --motion-ease-standard — pure CSS, so the reduced-motion token collapse stills it; there is deliberately no JS-timed stagger. Disclosure is the established trio (open/defaultOpen/onOpenChange, default open), with useId-derived trigger and panel ids.
StreamingText
ds-streaming-text — The reveal for text that arrives in chunks: an LLM response typing itself out. It owns no text style — white-space: pre-wrap and overflow-wrap: break-word on an inline span that inherits the surrounding typography, so it drops into a ChatMessage or Prose paragraph without changing its face. Feed it the accumulated text on every render and it animates through what was appended from a requestAnimationFrame loop that spends real elapsed time, so the pace is frame-rate independent and a fast stream renders at most once per frame. The rate scales with the backlog rather than being fixed: a thin trickle types at MOTION_STREAM_FLOOR_CPS (70 characters per second, from tokens/motion.ts) and however much is waiting is fully on screen within MOTION_STREAM_DRAIN_MS (250ms), so a dumped paragraph catches up in a beat instead of typing for seconds; floorCps and drainMs tune both per instance (charIntervalMs, the retired interval prop, maps to the floor when floorCps is not set). A value that does not extend the previous one is a new message and reveals from zero, while text present on mount shows whole. The engine is published headless from the same folder — useStreamReveal (declarative) and createStreamReveal (imperative), via components/StreamingText/useStreamReveal — for a consumer whose streamed text needs its own renderer: the site chat paces its markdown through createStreamReveal and renders it itself. The cursor is a 0.55em × 1em currentColor block at --radius-050 blinking on a stepped opacity keyframe at --motion-duration-loop-spin, shown while streaming or while the reveal is behind. Under prefers-reduced-motion the JS reveal is skipped (each chunk appears whole) and the token collapse stills the cursor. onRevealComplete fires once when the reveal catches up after streaming ends. Announcement is the container's job — pair with aria-live when the surrounding UI does not already announce the message.
ThreadPanel
ds-thread-panel — The session-history rail of a chat or agent product: brand header, a new-thread action with an optional shortcut hint (newThreadKeys in the shortcut grammar, or literal newThreadShortcut keycaps), standing control rows, the grouped thread history, and a footer for the profile row and host furniture. Deliberately surface-less: the host paints the band it sits on, so the component fills its container's height and scrolls exactly one region — the history <nav>, behind a tucked-away thin scrollbar (scrollbar-width: thin, no track fill) that only shows its --color-divider thumb under the pointer. A project row with no icon wears its generated PixelAvatar, the default project mark, seeded from the label (or from pixelAvatar, a stable id, so a renamed project keeps its character); the panel spreads the inks across its projects with distinctPixelInks, so neighbours differ in colour as well as shape. A pinned row reads pinnedLabel (default "Pinned") after its title and any status, never on a pending row, since the pin mark itself is decorative. Each row's options name, threadMenuLabel, takes a string (read as "label: title") or a function of the title, for languages that put the title elsewhere.
The rows are AppSidebar's shared pill shape with the inset moved up a level: the panel itself carries --padding-300 inline padding in both states, and every 40px --radius-pill row fills the padded column at width: 100% — one hover shape for the whole surface, every fill stopping at the same 12px breathing room. Rows hold --padding-200 inline padding in both states: the 20px seat (12 + 8) is what the collapsed circle's math needs (12 + 8 + 24 + 8 + 12 = the 64px rail) and what the expanded panel's marks keep — the logo and the pen icon's box share it — so no mark shifts as the rail sweeps; the profile row is the rails' one profile block (App shells): a 40px Avatar on the rail's axis collapsed, gliding to the leading line expanded, the name over the meta line in EntitySwitcher's pairing (App shells). The rail keeps App shells' geometry: the brand row 20px down, the collapse toggle's box on the 20px trailing line, a row's trailing meta 24px in, the group headings and the footer controls on the leading line with the logo; the text-led content that starts a row (thread rows with their dots, the more row, the rename field) takes --padding-300 at its leading edge, a bearing deeper than the icon seat, so dots sit under the pen's visible edge. The toggle washes a --color-action-passive-bg-hover circle on hover and carries a Tooltip of its label, opening leftward because the rail clips its overflow (collapsed, the native title stands in). Rows sit one --gap-100 beat apart, hover washing to --color-action-passive-bg-hover over --motion-duration-fast --motion-ease-standard, focus drawing the house --color-action-primary-bg ring inset. The new-thread row is deliberately as quiet as the control rows — the leading position and the glyph carry the affordance, not a fill — wearing edit_square by default, the same pen-in-a-box chat headers use for New chat (newThreadIcon overrides), with compact Kbds for the decorative shortcut hint (the host owns the actual key binding). Control rows carry --color-icon-primary icons and --font-paragraph-sm-* labels. Thread rows share the action rows' 40px shape exactly, stacked a hairline (1px) apart so adjacent fills never merge into a slab: a one-line title and an optional trailing cluster — a --color-icon-secondary session glyph (icon, e.g. cloud), a keep pin mark (pinned — a marker only; the host groups pinned threads and offers pin/unpin through the row menu), and the meta caption in tabular-nums — with an optional status dot leading the title: a decorative xs StatusDot drawn from the thread's status. The five statuses follow StatusDot's reading — idle a hollow neutral ring (nothing new, nothing running), working a pulsing neutral dot (an agent is running now; monochrome like AgentStatus's working states, colour reserved for the states that ask something), and unread, waiting and error solid dots in the info, warning and error roles (finished and unseen, blocked on the reader, stopped on a failure) — so no state rests on motion alone: under reduced motion working parks as a solid dot, still distinct from the ring. The dot column is all or nothing: once any thread carries a status every row draws a dot, the rest reading idle, so titles share one left edge, while a panel with no statuses stays dotless; a pending row in a dotted panel reads working. The dot is decorative, so every status but idle is also read aloud after the title through statusLabels — except on a pending row, whose accessible name is its tooltip alone (the boolean unread survives as a deprecated alias). A description puts a quiet second line under the title — the --font-caption-* face on --color-text-tertiary, the row wrapping to height: auto at min-height: 40px with its gap gone column-only so the lines sit close, the second line indented past the dot's column when the panel has one — under the same trailing mask as the title, so detail rows and plain rows fade out the same way. A long title dissolves under a trailing --gap-1000 mask (the alpha-keyword gradient, ChatThread's manner) rather than clipping to an ellipsis, with the full text in the row's native tooltip — the fade is also where the overflow trigger lands, so revealed text and control never fight. The active row (matched by the controlled activeThreadId) fills the column with --color-bg-container-tertiary and announces aria-current. Group labels (and the projects header) are the rails' one group heading, --font-paragraph-sm-* in --color-text-tertiary, mixed case, on a 20px line so the rhythm holds, on the leading line, carrying the rows' own --padding-200 vertical inset — as if the label sat inside a row — so the heading keeps the pills' rhythm instead of hugging its neighbours; the more row is a quiet --font-caption-* reveal that brightens one text step on hover. The brand header takes a --gap-300 bottom margin, seating the logo evenly between the panel's top edge and the new-thread row.
Thread rows carry a real history's lifecycle, all of it controlled from outside. A pending thread is one whose name is still being generated: a full-width --radius-pill shimmer bar at 0.75em height, inset only by the row's padding and softened at its trailing end by the title mask (--color-bg-container-secondary under Skeleton's sweep at --motion-duration-loop-shimmer; the component restates the sweep rather than composing Skeleton, whose role="status" label would hijack the row's accessible name) holds the title's place, the given title — typically "New chat" — stays as the row's tooltip and accessible name, and the row shows no menu until the name lands. threadActions (with onThreadAction; a thread's own actions overrides the set) hangs an overflow menu on every row: a 28px circular more_horiz trigger absolutely positioned over the faded trailing edge — the <li> is the positioning frame, since a control cannot nest inside the row button — opening the compact DropdownMenu aligned end, destructive rows in the menu's own error styling. The trigger rests at opacity 0 with pointer-events: none and reveals only on row hover, on :focus-within, and while its menu is open (held via :has([aria-expanded='true']) — the value is load-bearing, since the closed trigger carries aria-expanded="false" — which also keeps the row's hover wash on) — never standing on a resting row; the trailing cluster (glyph, pin, meta) fades out as the trigger fades in, since they share the trailing edge. renamingThreadId swaps that row to the inline rename field: an uncontrolled input wearing the row's exact shape and --font-paragraph-sm-* face with a --border-025 --color-action-primary-bg border (the active-input colour), prefilled and selected on mount. Enter commits the trimmed value through onThreadRename, Escape cancels through onRenameCancel (both hand focus back to the row), focus leaving the field commits too, and an empty or unchanged value cancels rather than committing — half a title is not a title.
Between the standing controls and the history sits the optional projects section: rows sharing the controls' exact anatomy (the project's generated PixelAvatar by default, any Material Symbol via icon, an optional meta caption that fades with the labels) under their own header in the group labels' face, carrying their seat — its vertical inset stepped down to --padding-150 so the 24px new-project button (add, rendered only when onProjectCreate is wired) doesn't grow the header past the label-only group rows below. The active project (matched by the controlled activeProjectId) takes the same --color-bg-container-tertiary fill and aria-current as an active thread. Collapsed, the header fades out whole (opacity plus visibility, the history's manner) while the project rows fold to icon circles like the controls, keeping the section present on the rail.
Fully controlled and stateless — the host owns selection, navigation, the expanded state, the pending flags, and the rename state. Every row follows Button's element pattern (<button>, or <a> when an href is given), each section renders only when its props do, and the component carries no 'use client', so a link-driven history renders from a Server Component; the menu and rename affordances take callbacks, so they belong to client hosts by nature. The footer composes Avatar for the profile row; footerSlot seats host furniture like a theme toggle above it.
Collapse is AppSidebar's choreography wholesale: expanded (default true) with onExpandedChange, whose presence renders the header's toggle (left_panel_close / left_panel_open, announcing aria-expanded). The panel owns its width — 280px expanded, the 64px rail collapsed, AppSidebar's own constants — transitioning on --motion-duration-slow --motion-ease-emphasized while every label stays mounted and fades on --motion-duration-base --motion-ease-standard under the sweeping clip, so inner layout never snaps. Collapsed, the toggle folds to zero width and the logo takes over as the expand button (AppSidebar's role="button" pattern; with no logo the toggle stays standing and centres on the rail's axis, the empty brand slot standing down) — and the rail's dead space is an expand target too, a click anywhere that is not a row reopening the panel — the rows read as circular icon buttons, the footerSlot fades out (crossfading with collapsedFooterSlot, the same controls at their smallest stacked on the rail's axis, when the host gives one: AppSidebar's two-form footer), and the history — whose thread rows have no icons to collapse to — fades and leaves the accessibility tree and tab order via visibility. The host pairs the state with its own band, which simply follows the panel's animating width.
ThreadTabs
ds-thread-tabs — The strip of open chat sessions across a conversation's top edge: 32px --radius-pill pill tabs with an optional leading glyph (--icon-size-500, --color-icon-secondary), ThreadPanel's status dot (the same xs StatusDot and the same statuses, except that an idle tab draws none: a strip has no left edge to keep, so only a state worth a glance takes the room), a --font-paragraph-sm-* label, a hover-revealed close button, and a trailing 28px new-tab action. Deliberately surface-less like its sibling rail — the host paints the band it sits on. Labels sit on --color-text-secondary and step up to --color-text-primary on the active tab, whose pill fills with --color-bg-container-tertiary — the same "on stage" mark as ThreadPanel's active row, so the strip and the rail speak one language. Hover washes --color-action-passive-bg-hover; a long label caps the tab at 220px and dissolves under a trailing --gap-400 mask with the full text in the tab's tooltip (the panel's manner); a crowded strip scrolls sideways with no scrollbar. Not an ARIA tablist: like the panel's rows these are a labelled list of buttons selecting sessions, the active one announcing aria-current. Arrow keys, Home and End move focus along the strip; Delete closes the focused tab when onTabClose is wired. Each tab's close name, closeLabel, also takes a function of the tab label, for languages that put the label elsewhere.
The close button is geometry-stable — a 24px circle that always holds its seat and only fades in (--motion-duration-fast) on tab hover, keyboard focus within, or the active tab, so revealing it never shifts the label; closing from the keyboard hands focus to a neighbour before the tab leaves. Enter and exit are the component's own choreography, the grid-track idiom turned sideways: each item is a one-column grid animating between 0fr and 1fr (with its own leading --gap-100 margin folding in and out), so an added tab glides open in place on --motion-duration-slow --motion-ease-entrance and a removed one — held on stage in a leaving list until its animation ends — folds away on --motion-duration-base --motion-ease-emphasized, the neighbours sliding over as the column collapses. No measurement, no FLIP; the initial set renders calm, only tabs that arrive later animate. Fully controlled: the host owns which tabs exist and which is active.
ToolCall
ds-tool-call — The record of one tool invocation. A skimmable header row — the tool name, in the chat cards' one-row label style (--font-paragraph-sm-emphasis-* on --color-text-primary, so the headers of a thread read as one set; the code stack stays for the code inside), summary (which yields its room first; a name still too long ends in an ellipsis, so the status word always stays in the card), the status indicator (trailing, beside the word it stands for), status word, tabular-nums duration, chevron — over a collapsible body holding the arguments and result. Container is --color-chat-card-bg at the chat furniture's shared --radius-600 shell with a --border-025 --color-bg-container-border, header and body inset --padding-500 from the sides; a closed row rounds to a capsule the way a one-line bubble does, echoing the system's pill buttons. The panel uses the same 0fr → 1fr collapse and visibility handling as Reasoning.
Four statuses: pending, running, success, error, mapping to the warning, info, positive and error roles through two channels — the status word in the --color-status-*-text tokens, the indicator glyph in the --color-status-*-icon tokens (the plain-surface step; see the Status section). The container stays neutral so a long run reads as a list rather than a wall of tinted cards — the border is the standard container hairline in every status, and the status word and icon carry the state. running renders a Spinner at tone="inherit", the rest a Material Symbol (pause_circle, check_circle, error) at --icon-size-500.
The actions slot — allow, deny, always allow — renders in a single-tone footer on the card's own background, separated by whitespace alone, outside the collapsible panel, so answering an approval request never requires expanding the call first. With no children the header renders as a plain div rather than a button, so a row with nothing to disclose does not look pressable.
UsageCard
ds-usage-card — An agent's budgets at a glance: the context window plus any number of plan or rate limits, one compact Meter row each, with reset captions. It wears the chart family's shared card chrome — the root composes ds-chart (and ds-chart--bare via bare), the header is ds-chart__header with the family's title and subtitle styles — the Gauge/FunnelChart precedent of chart chrome outside the Chart/ folder, though the component registers under the ai category because its vocabulary is context windows and agent budgets. Rows stack at --gap-500; each pairs a size="compact" Meter (label left, readout right, valueText overriding the percentage for token counts and spends) with an optional resetLabel caption on the bar's trailing edge in --font-caption-* --color-text-secondary.
Fills recolour through Meter's status variants as a level crosses the card's thresholds — info until warning at 0.8 of max, error from 0.95, both fractions tunable via thresholds, mirroring Gauge's threshold idea — and an item's own variant pins the derivation aside (a frozen quota can stay neutral at 100%). Everything visual belongs to Meter and the chart chrome, so the component adds no tokens and no motion of its own; the a11y contract is Meter's role="meter" per row. Deliberately server-renderable: no 'use client'.
Waveform
ds-waveform — Voice made visible: a row of --radius-pill bars that dance while audio is happening. The animated states are pure CSS choreography on the shared twelve-slot cycle (--motion-duration-loop-matrix, the same loop AgentStatus's dot patterns run on): each bar carries a centre-weighted peak (--ds-waveform-amp, outer bars reaching lower so the wave reads as one shape) and a cycle slot (--ds-waveform-step, adjacent bars two slots apart, applied as a negative delay so the peak travels the row and every bar is mid-wave on mount). state: idle parks the bars low in --color-text-tertiary; listening runs the wave at roughly half height; speaking at full — one keyframe set, scaled per state through --ds-waveform-peak. levels switches to controlled mode for a real analyser: the CSS wave stops and each bar eases to its 0–1 level over --motion-duration-fast. Deliberately monochrome like the agent working states (--color-text-secondary), tintable through --ds-waveform-color. Height rides the icon scale (--icon-size-600 at default, --icon-size-500 at compact), and each bar's width is a fifth of --icon-size-500 at both sizes. Root is role="status" with a visually hidden state label (defaults per state, label overrides); the bars are aria-hidden. Under reduced motion the wave parks on its resting contour rather than strobing. Purely presentational — no 'use client'.
ChatWidget
The chat is the package's fourth kind of thing, between the components and the templates, and its only widget: a complete AI chat a consumer mounts end to end and configures with props, shipped as rift-ds/chat (the composition and the engine), rift-ds/chat/server (the handlers), rift-ds/chat/upstash (the guardrails) and rift-ds/chat/demo (a scripted conversation for a preview that needs no key). src/chat/registry.json is its registry; the Chat widget row in CLAUDE.md's Registries table owns the fields and the validator. Three components make the widget: ChatProvider holds the conversation (the transcript, a stream in flight, the draft, the open state, the placement and the thread lifecycle) and is mounted once, high in the tree, so client-side navigation never remounts it; ChatWidget is the surface, filling whatever hosts it; ChatLauncher is the floating button and the fixed panel in its docked, overlay, floating and takeover forms. Under them sits the engine, importable on its own by a host that wants the behaviour under its own markup: useChat (the state machine), useThreads (the thread and tab lifecycle), createFetchTransport and scriptChat (the two transports), and the ChatEvent protocol in src/chat/types.ts, which the server handler and both transports are type-checked against.
The library components the widget composes are listed under parts in src/chat/registry.json, the one home of that list, which scripts/validate-chat-widget.mjs holds to the widget's own imports in both directions. Two of them have roles worth naming: AiButton is the launcher, and PixelAvatar is the agent's mark on the header toggle and the pane's portrait. Its class block is ds-chat-widget (ds-chat-widget__* for its elements, ds-chat-launcher__* for the launcher's chrome), deliberately not ds-chat-*, which the library's chat components already own. One stylesheet, ChatWidget.css, published as rift-ds/chat/chat.css; every value in it is a semantic token or a sanctioned restatement (the sub-display step it restates for its container width carries the typography directive). Every control name the widget writes is a *Label prop with an English default (the header and rail controls, the scrims, the model picker, send, the reply row's copy and thumbs, and the follow-up row), so a consumer can localise everything it announces; ChatLauncher passes them through widget. Every word it writes on screen is a prop too, with its English text as the default: the composer's context note (the context object's note, defaulting to Looking at “<label>”; the deprecated contextLabel and contextNote build the same object), the note's clear button (contextClearLabel), the drop overlay's limits line (attachHint), the welcome greetings (greetings), the Chat position menu's rows (placementLabels), the agent pane's default status (agentStatus), and the artifact panel's empty state (artifactPanelTitle, artifactEmptyTitle, artifactEmptyDescription). Interpolated copy is a function prop, fixed copy a string, and a keyed set takes a partial object, so a key left out keeps its English. A host that seats the built-in parts itself gives each its words directly: ChatArtifactPane takes emptyPanelTitle, emptyTitle and emptyDescription for its empty state; ChatAgentPane takes profile (whose status is the line the widget fills from agentStatus) and closeLabel; ChatTabs takes ariaLabel, closeLabel and statusLabels. The words its composed parts write are props too, one per component, named <component>Labels and typed from that component's own props (composerLabels, attachmentDropZoneLabels, attachmentGroupLabels, chatThreadLabels, modelPickerLabels, artifactPanelLabels, agentRailLabels, questionCardLabels, questionSummaryLabels, codeBlockLabels, promptSuggestionsLabels): each key replaces one string and a key left out keeps the component's English. The threads rail takes its words through threadsProps (threadPanelLabels, actionLabels), and its footer through threadsProps.footerSlot (in both seats, above the profile row on the inline rail, fading out while that rail is collapsed, ThreadPanel's contract), the tab strip through tabsProps, the reasoning summary through reasoningLabel, and the launcher's summary panel through ChatLauncher's aiButtonLabels. The headless useThreads takes labels for the names it writes into its state (the row actions, and a new project's name and meta), so a host on its own UI localises them too.
A transport is the whole integration. (messages, signal) => AsyncIterable<ChatEvent>: the fetch transport parses the NDJSON a createChatHandler route streams, a script plays a conversation written in scriptChat(), and a host's own transport yields whatever it likes. The events are status (a label and an optional reasoning trace point), model, models (the server's default and locked wire values, which the picker follows), delta, exchange (the server's log id, whose presence is what shows the thumbs), followups, content (a React node, for in-browser transports), card (serialisable rich content a server can send, drawn by the provider's renderers by kind), question (questions the agent asks with answers to pick), notice, error and done, which always comes last. A content or card event draws on the live reply the moment it arrives, after the reasoning and before the prose, so a tool's result shows while the answer still streams, and stays with the turn when it commits. It also ends the live status line: a reply that opens with its answer has nothing to show thinking about, and only a trace point brings the reasoning disclosure back. A card sent with lead is the exception: the host's own opening (the readout of a change the reader just confirmed), it draws first under the status line and leaves the line running, held to one row for the whole reply (the trace opens only when the reader asks, and the line stays on as the summary when the thinking ends), so the thinking never moves the card. The hook paces deltas through createStreamReveal and renders notices and errors whole; an error replaces streamed text, so the handler only ever sends one before the first delta. CHAT_EVENT_KINDS enumerates the protocol for documentation that lists it.
Rich content inside a turn. A reply may carry a card: the server handler forwards one a tool returns, a script attaches one to a reply, and injectTurn stages one directly. ChatProvider's renderers map a card's kind to the component that draws its data, so a chart, a plan, a diff or an interrupt card renders where an agent's structured output belongs: after the reasoning, before the prose that reads off what it shows. The demo's DEMO_RENDERERS draw every kind its story uses; a kind with no renderer draws nothing. A reply may carry several, drawn in arrival order (cards on the turn, with card the latest), and a card is a reply on its own: a turn that ends with a card or content and no text commits without the empty-reply placeholder, and stays out of the transport history because it has no words to record.
The entry button reports the agent. useChatAgentState() reads the conversation as an AgentStatus state: thinking until words or rich content arrive, working while they do, waiting while a question stands unanswered, and done or error for MOTION_FEEDBACK_RESET_MS as a reply ends. A reply the visitor stopped, marked by the turn's outcome, ends in idle. ChatLauncher passes it to its button, and a host with its own entry point passes it to its own AiButton, so a reply closed away from still shows its progress. The hook lives above the button, because a button that remounts on close must not report an ending that already happened.
The composer's size follows what it holds, with no prop to set: with a model picker, attachments or composerActions on, it is the two-row composer with its footer; with none of them, the Composer's inline="chat" row. A context note is not footer content: inside (contextPlacement's default, as on the Composer) it rides at the top of the shell, over the footer composer's text or on a line of its own over the one row, and above gives it a bar of its own. Both grow to twelve rows, the Composer's own default. The composer's leading action is a ModelPicker over the host's models list. The pick is a request, not a command: it rides on each send as a wire value, and the handler resolves it through the day's budget tier before calling a model. The picker shows the visitor's pick, or the server's current default when they have not made one (which is how a budget step-down moves the label with no click), and an entry the budget has locked stays listed but disabled, its description swapped for lockedModelDescription, so the lineup reads as "paused", never "gone".
The rails. threads seats a session-history rail: true for the built-in ChatThreads, a ThreadPanel over the provider's useThreads (a thread is born from the first utterance and lands at the top of the home group already shimmering, named a beat later; the row menu pins into the leading pinned group, renames inline and deletes; selecting a thread moves the selection, never the transcript), or a render prop handed { overlay, close } for a host-shaped panel. The widget measures its own width and seats the rail by what it finds: at THREADS_RAIL_MIN_WIDTH (720px) and up an inline rail, below that a header menu button opening a scrim-backed slide-over sheet, so the mode follows the container, not the viewport. Growing into rail territory retires the sheet; Escape settles the sheet before the panel, one layer per press; with the inline rail showing, the header's New chat stands down because the rail carries its own. tabs seats a ThreadTabs strip under the header (true for the built-in ChatTabs, synced to the same lifecycle: a fresh thread arrives with a tab, closing the last returns to the greeting). aside is the trailing edge's mirror: a render prop for a host-shaped AgentRail, or agent={{ tabs, profile }} for the built-in ChatAgentPane, inline once the widget measures ASIDE_RAIL_MIN_WIDTH (760px) and otherwise raised from the header's agent toggle as a bottom sheet capped at 82% of the widget's height, with a decorative grabber. The floating card's move handle and size grips are pointer-only by decision, like the dock's widen grip: the Chat position menu is the keyboard route to every seat, so the grips are a convenience over it, never the only way, and an audit does not raise them. The inline pane glides open and closed rather than appearing, in the artifact panel's manner: the rail's width runs from nothing to the pane's own on the slow duration with the layout easing the threads rail uses, the card is the rail at every frame (a whole rounded card growing from the trailing edge, the pane revealed behind its leading border) and fades in behind the growth, and closed it stays mounted but inert and invisible, so the toggle reads as one motion in either direction. A rail holds a width of its own, so the widget measures the pane and hands the stylesheet the target as --ds-chat-aside-width. The panel is one piece of state, resting closed and opened only because someone asked: a click on the toggle, or the host's agent.defaultOpen, which opens it at mount and only where the widget is wide enough to seat it inline (a widget that mounts narrower never raises a sheet for it); leaving rail territory closes it rather than handing it to the other seat, and the artifact panel follows the same rule. The header's agent toggle wears the agent's own PixelAvatar, seeded by the widget's title and the same mark the built-in pane's portrait defaults to, so the control and the panel it opens read as one character; it holds its seat whether the panel is up or not and says which, wearing the passive wash while on; the whole control cluster sits at the card's right edge, ahead of the rail in the DOM (The controls' one home, below, owns that rule). These three slots are visual staging: switching a thread, a tab or a pane moves the selection, never the transcript. A fourth, widgets, belongs to the welcome screen alone: any content under the composer, gone with the first message. A fresh welcome arrives rather than appears: the composer rises in at once (--motion-duration-slower on the entrance ease), then the greeting on --motion-duration-slow, then the slot's tiles (a WidgetGrid's children, or the slot's content as one piece) pop in on --motion-duration-slow a quick step apart, so a dashboard snaps together, on every new chat. The greeting and the tiles wait a short beat first, the composer alone standing on the ground, so the ground (a shader scene, the page) registers before the board lands: --ds-chat-widget-welcome-hold, --motion-duration-deliberate by default, which a host sets on the widget or an ancestor to lengthen or zero. Reduced motion collapses the whole sequence to an instant. widgetsLayout says how it takes its room (fit keeps the welcome to one viewport, the composer at the same dead centre the starters give it and the widgets hanging below, riding up only by what they overflow; scroll turns it into a page, for widgets that run below the fold) and widgetsWidth how wide (column on the composer's box, wide past it).
The artifact panel. artifacts seats the trailing edge's other tenant: an ArtifactPanel for something the chat made that wants more room than a turn. An artifact is a ChatArtifact (an id, a title, a kind, and whichever of an HTML document, a URL, source and renderer data it carries) and it travels as an ordinary card of kind artifact, so any transport that can send a card can send one and the event protocol gains nothing. The thread draws it as an ArtifactCard through the exported ChatArtifactCard, which opens its artifact and, pressed again, closes it; ChatArtifactPane is the panel half, an ArtifactPanel drawn from the artifact, and both are exported for a host building its own thread. The pane is controlled by design: the widget (or a host seating the pane itself) owns the seat's state, which artifact is on stage, whether the panel is up and whether it is expanded, and passes artifact and expanded in, hearing back through onExpandedChange and onClose. The header carries an artifact toggle beside the agent's (right_panel_open / right_panel_close, the same passive wash while on) that opens the conversation's newest artifact, or an EmptyState when the conversation has not made one yet, and closes whatever is up. A kind named in the provider's artifactRenderers is live React content; otherwise html is framed as a sandboxed document with its source behind the Code view, url is framed only when it is an http(s) URL or a same-site path, and code alone shows as source. An artifact that arrives while the visitor is watching opens itself where there is room to seat it inline; in the sheet's territory it waits for its tile, because a sheet nobody asked for covers the answer that announced it. Every road back to an empty thread takes the artifact off stage.
The seat is the agent rail's, exactly, and so are its rules. The trailing seat has one threshold for both tenants: at ASIDE_RAIL_MIN_WIDTH and up the artifact is an inset --radius-600 bordered card beginning under the header row with --padding-500 to the trailing and bottom edges, as the agent pane is, and below it each rises as a bottom sheet. Both trailing panels open and close as drawers: the card is solid from the first frame, anchored to the seat's trailing edge at the width it rests at, and the seat hides only once it has shut; a swap fades the leaving card out at once. The artifact rests at --ds-chat-artifact-rest-width (520px), held to the room the widget measures before the conversation drops under its floor (--ds-chat-artifact-room), until the grip sizes it, and its sheet stands at 92% of the widget's height rather than the agent pane's 82% cap, because a preview has no natural height to hug. Both trailing panels stand on --color-bg-page-primary, solid in either seat, where the widget's own surfaces and the threads sheet are glass: a panel is a place to read and work in, and what shows through glass would sit under its content. The trailing seat holds one panel at a time: an artifact on stage stands in for the agent pane, which returns when the artifact closes if it was up, and asking for the agent swaps them. Under ASIDE_WITH_THREADS_MIN_WIDTH (1040px) the inline threads rail yields its room to whichever trailing panel is up, the agent pane as much as the artifact, and goes back behind the header's hamburger; and a widget that narrows out from under either panel closes it rather than raising a sheet nobody asked for. The card's leading edge is a size grip, the dock's widen grip restated: a 20px grab zone straddling the border with a hover-revealed 4×48px pill, dragging the panel's width while both columns keep ARTIFACT_MIN_COLUMN_WIDTH (320px); pointer-only by the same decision as the other grips. The panel's own expand toggle gives it the conversation's room: the conversation folds out of the row and the tab order while the card glides across, the history rail keeps its place, and Escape steps it back. The glide trades the two columns' grow shares and nothing else, both bases holding still (a basis gliding against a share bows the split, and the conversation swells before it folds), and a grip-sized card is held to the measured room as the resting one is. Both trailing panels carry a close of their own in the top trailing corner as well as their header toggle.
The controls' one home. Wherever the widget is wide enough to seat a trailing panel inline, the header's control cluster lives at the card's own top trailing corner for good, over the header row with no panel up and over the panel's top inset with one, and the chat header keeps a spacer of the cluster's size so the title still stops short of it. The cluster used to move between the header and that corner as a panel opened and closed, which remounted every button and, on the way out, landed them on a column that was still narrow; one home means nothing moves. For the same reason the header's New chat is keyed on the threads rail having room, not on whether an open artifact has borrowed it, so the cluster never gains a button because a panel opened.
The launcher's panel is a floating card: position: fixed against one viewport edge (the right by default) at --ds-chat-width (420px at rest; DOCK_MIN_WIDTH mirrors it), inset --ds-chat-inset (20px) from the top, bottom and docked-side edges, at --radius-600 with the container border and --shadow-floating, on the --color-bg-glass surface behind a backdrop-filter: blur(24px). The three variables are declared on :root by the stylesheet and are the one contract a host's own CSS reads (the site aliases its --layout-chat-* to them). Docked, a hover-revealed grip on the inner edge drags the width up to DOCK_MAX_WIDTH by rewriting --ds-chat-width inline on <html>, so everything a host sizes from it slides with the edge. Opening fades the card in with a 10px rise on --motion-duration-slow / --motion-ease-entrance; closing mirrors it on --motion-duration-base / --motion-ease-standard, the provider holding the panel mounted for MOTION_EXIT_SYNC_MS with pointer events off. The move between geometries is a glide on --motion-duration-slow with --motion-ease-emphasized; fullscreen view drops the inset and radius and raises the blur to 40px.
As a page. layout seats the widget in one of the three app shell modes (App shells owns them) for a host where the chat is the page, a chat home, rather than a panel over one: the widget's outermost element carries the shell recipe's markers and its rails read them. flush runs the history rail on the shell colour behind its hairline with the conversation and the room around the trailing panels crisp beside it, and takes backdrop, the lever only a chat has: blur (the default, the site chat's takeover look) when the chat rests above something, solid when it is its own page; floating lifts the history rail into the glass card and raises the trailing panels' edge; inset sets the conversation and each trailing panel in raised surfaces at the inset gap, seats the widget's header controls in the panel's top row (centred between the card's edge and the artifact header's divider, with the artifact header keeping room for exactly as many controls as float over it, so its own expand sits the controls' gap from them), and drops the panels' own close, which the header toggle now does. Expanded, the artifact takes the raised conversation's exact seat, flush to the history rail, the conversation's gap and hairline folding away with its width. Left out, the widget draws no shell and takes its host's ground, as a docked or floating panel does. A raised mode needs room for its gap and edges, so a widget narrower than THREADS_RAIL_MIN_WIDTH (720px, measured on the widget, not the viewport) lays floating and inset out flush on the solid backdrop, and returns to the chosen mode when it widens again; below the mobile breakpoint every mode gives way to the plain full-screen chat.
The inset surface. ChatLauncher's surface="inset" seats the panel beside an AppLayout (or any host) in the inset layout. Docked, the panel is the inset surface's twin: --color-bg-app-surface with the same raised edge (the hairline and --shadow-floating) and no blur, held off the edges by --ds-shell-inset (declared on :root by AppSidebar's stylesheet, which AppLayout's inset layout reads too; the chat falls back to the same value without it) instead of --ds-chat-inset, so it lines up with the content surface beside it, and the host reserves its width plus two shell insets. As the overlay below the dock threshold it takes the same gap but keeps its shadow, since it still covers the page. The floating card and the takeover are unchanged. raised, the default, is the glass card described above; it is not called floating, as AppSidebar's glass rail is, because the chat already spends that word on a placement (CHAT_PLACEMENTS).
The three responsive modes are mandatory for every AI chat surface, not only the site's. At DOCK_QUERY (1440px and up) the panel docks: the launcher sets data-chat="docked" (or docked-left) on <html>, the host's stylesheet pads the page beside it, and the panel is a non-modal complementary region with no scrim and no focus trap. It docks only while the page keeps the room that threshold promises (DOCK_PAGE_MIN_WIDTH, the narrowest docking viewport less the panel's resting width): a panel widened past it, or a window narrowed past it, takes the overlay instead, settled once as the grip lets go. The widen grip rides the overlay too, in the same DOCK_MIN_WIDTH to DOCK_MAX_WIDTH range, so a panel narrowed back inside the page's room docks again on release; useDockViewport gives a host that reserves the panel's room the same answer. Below that it is a modal overlay: role="dialog", --color-scrim behind it, focus trapped, body scroll locked through the library's shared counted lock (so a Dialog and the panel can close in either order), Escape closes. Under TAKEOVER_QUERY (720px) it is not an overlay at all: the panel leaves the fixed layer for normal flow, as tall as the visual viewport (iOS Safari never shrinks 100dvh for the soft keyboard, so the launcher writes visualViewport's height and pan onto the panel and the composer ends at the keys with the header still on screen), the launcher sets data-chat="takeover" and the host hides the page around it, so the chat is the document (a viewport-sized fixed panel is exactly what iOS Safari renders short under the keyboard). Nothing else on the host may make the document taller than the panel while it is open, or Safari scrolls into the spare height when the composer takes focus; the site pins its ambient background for that reason. The widget's phone layout stacks the welcome there: greeting centred in the empty thread, starters directly above a bottom-pinned composer; short-viewport container queries drop the welcome dressing in order, greeting first, then starters. A host reserves the panel's width only at the dock threshold, never below it, or the page squeezes to a sliver beside the chat. Two exceptions: a panel seated inside a frame of its own answers to that frame, and a screen where the chat is the page holds the takeover form at every width with closeEnabled and fullscreenEnabled off. TemplateAssistant restates the modes over the same thresholds (src/chat/queries.ts).
Placement is an option on top of the pattern, not a fourth threshold. With placementEnabled, the header carries a Chat position menu whose three rows are CHAT_PLACEMENTS (dock right, dock left, floating; src/chat/placement.ts is the roster): the dock side is the docked mode mirrored, and floating is a non-modal card the visitor moves by its header and sizes from eight hover-revealed grips, between its resting size and FLOAT_MAX_SCALE of it, never closer than FLOAT_EDGE_GAP to a viewport edge. Full screen has no placement, so the trigger goes inert there; a phone has neither menu nor grips. A host that offers placement implements every seat the menu names; one that does not leaves the prop off.
The welcome screen greets by the visitor's clock (greeting.ts, read as a store so prerendered HTML never bakes in a build machine's hour) over the host's tagline, with starters under the composer: a few pre-written questions the host draws from its pools with pickStarters and the provider's seed (rolled per load and on New chat, never between), staged with PromptSuggestions' pending shimmer for MOTION_SUGGESTIONS_THINK_MS the first time a startersKey shows and standing still on every later open. Every suggestion the chat shows, starter, follow-up or launcher chip, is held to SUGGESTION_MAX_CHARS: a chip never wraps, and one that will not fit is dropped rather than clipped.
Answers render as markdown through Prose, fenced code as CodeBlock. Model output is untrusted, so only an href the provider's isInternalHref accepts (same-site slug paths by default) becomes a link, drawn by renderLink (by default the nearest LinkProvider's link component, else a plain anchor; a host may pass its own), and following one closes a panel that covers the page. Each committed assistant turn carries a copy action and, when the server logged the exchange and the provider has a feedback sink, thumbs up and down that post the exchange id and the verdict optimistically. Under that row sit the follow-ups: the same PromptSuggestions row the welcome uses, arriving a beat after the answer commits from the provider's followups source (the built-in fetcher against the follow-ups handler, or a host's own), the pending shimmer shown only under the newest settled turn, and a result that comes back empty substituted with fallbackFollowups, because a shimmer that resolves to nothing reads as a failure. Once a chip is sent the row keeps the choice, through PromptSuggestions' value, with QuestionCard's own treatment: the sent chip outlined solid in --color-text-primary, the rest faded to 0.4 behind a dashed --color-bg-container-border line, all inert. No indicator is added, so a chip keeps the shape of the line of text it is; a picked option differs by its indicator and its input surface, so the transcript shows which way the conversation went in one visual language.
flow passes straight to the thread: top-down (the default, the send choreography) or bottom-up (the classic thread), offered as the Flow lever in the playground's Chat setup.
Quick replies: suggestions and questions. A reply ends in one of two things, never both. Follow-up chips are the visitor's voice, guessed after the answer by a small model. A question is the agent's, asked by the model that wrote the answer because the answer stopped for want of it: it arrives as a question event carrying a ChatQuestionSet, and a turn that carries one is never asked about for follow-ups. The set renders as a QuestionCard straight under the prose, above the actions row, live only while its turn is the newest and nothing streams; passed by without an answer it stays, inert, as a record of what was asked. Answering records the answers on the asking turn and sends them as the next message: one question reads as the plain bubble, several picks joined with commas, and a set as a QuestionSummary in its plain variant inside the bubble, the same record the asking card folds to. The message the model receives is plain text (serializeReply), so the transport and the server are unchanged. A set of more than one question holds the composer, disabled under questionsPlaceholder, with focus moved to the card's first option and Skip as the way out; one question never does, because typing instead is a fine answer to it. Every set passes sanitizeQuestionSet (src/chat/questions.ts) in the handler and again in the hook: option labels are held to SUGGESTION_MAX_CHARS, two to five options, at most four questions, and anything malformed is dropped, never repaired. The reply text asks in words too, so a dropped set still reads correctly.
The disclaimer line pins to the widget's floor in --font-caption-* on --color-text-tertiary: text a visitor can find but never has to read.
The server half mirrors the client's contract. createChatHandler takes the host's system blocks (with cache_control on the corpus block), models and budget roles, tools ({ definition, execute, tracePoint }, where execute(input, { context, request }) is handed the request's sanitised context and a scratch object every tool call in the reply shares, so a filter one tool sets is the one a later tool acts on, and may return a card; strict schemas compile into one grammar with a size limit, past which every request fails, and the handler names that in its log), the opt-in askUser (the built-in ask_user tool, whose guidance lives in its description so the cached system prefix is untouched; a well-formed call streams a question and ends the exchange with no further model call, a malformed one goes back to the model as an error), limits and copy, and returns a web-standard handler that streams the protocol above; guardrail outcomes are 200 plus a notice, non-2xx is reserved for malformed bodies, the tool loop is bounded by maxModelCalls with tools withheld on the last pass, and the exchange id streams only when a ChatGuardrails.onExchange logged the line. createFollowupsHandler and createFeedbackHandler are its siblings; createUpstashGuardrails implements the ChatGuardrails hooks over Redis (per-visitor rate limits, a daily breaker with model tiers, the exchange log and the feedback store), failing open when unconfigured. The Next route files a host writes, or npx rift-ds init chat writes for it, own the segment config and the environment reads; the package imports nothing from a framework.
A chat follows the reader. What the chat is looking at can follow what the reader puts in front of it: rows they tick, text they highlight. useSelectionFocus(root, onSelect) hears the selection inside one root (the page beside the chat, never its own panel) and reports the text with icon ligature names left out, a copy of the range, and the smallest element holding all of it, where the host reads the source: a data-chat-source on a card or a figure (CHAT_SOURCE_ATTRIBUTE), a table's tr[data-row] and [data-col]. A selection outside the root leaves the last one standing, so clicking into the composer keeps it. A context that changes without moving focus is spoken: the widget keeps a polite, visually hidden region, mounted empty and silent for the note it opens with, that reads the new note once it settles (MOTION_ANNOUNCE_DEBOUNCE_MS), so a drag across text speaks once. Typing moves the browser's one selection into the composer, so useHeldHighlight(range) repaints the range through the CSS Custom Highlight API as ::highlight(ds-chat-focus) on --color-chat-context-bg, the context note's own fill, so the passage and the note that names it read as one thing; a browser without the API simply holds none. The host names the focus through context (its label, note, icon, onClear for the way back, sent to repeat it on each message), and sends it with every request through ChatProvider's getContext (a stable function returning the request's context string, read at each send), where the server looks the source up rather than trusting the words. A word alone rarely says enough, so a highlight travels with what it sits in: describeSelection(selected, root) reads it from the page's structure, a heading standing for its section's text, words in a paragraph bringing the paragraph and the heading above, a piece of a code block the whole example, and a table cell its column's header and its row, with the nearest data-chat-source outranking all of it and ignore leaving out text that is never the page's own, such as a live demo. On the server, sanitiseSelection checks that shape and selectionLines writes it for a system block, quoting every word and naming each part as the host's own published text or the reader's selection through the host's isPublished. Highlighted page text reaches the model as data, so a page that carries third-party words (a customer's note) needs the same decision the site corpus does.
Starters a model writes. For a focus, useGeneratedStarters(request, { endpoint, active, fallback }) asks createStartersHandler (rift-ds/chat/server) once the selection settles (MOTION_SELECTION_SETTLE_MS), keeps each answer for the page's life, and holds the widget's shimmer through startersPending while the model writes. The handler's prompt names nothing about a product: six question intents (what it is, how it has changed, what makes it up, how it compares, the records behind it, what shares something with it), filled only from what the host's describe says, every chip neutral and broad (it asks, never characterises: no word that judges a value or reads a direction into it, no value or id restated), and held to the chip budget through parseFollowups. A vertical changes describe, never the prompt.
Only the model's words are its history. A guardrail notice and a failure message are the host speaking, so useChat records an assistant turn's deltas alone in the history it sends: a reply that failed outright adds nothing, and one that failed or stopped part-way keeps the words that did arrive. The model is never told it said "Something went wrong".
A chat proposes, the reader confirms. A change the assistant can make is a defineAction (its label, done, confirm wording, icon, destructive, and skip and apply over one record), defined once for the page's own buttons and the chat alike, and planAction is the dry run both show first. Its verb and effect are how a confirmation asks: a page's own confirm (an AlertDialog for a change that takes something away) titles itself "Cancel 3 orders?" with the verb, says what the change does with effect, worded for the count, and what it leaves alone, and the chat's ActionCard carries the same line in its detail, so the two ask one question in one set of words. The chat's tool only proposes: it plans the action over the records the request means and hands the page an ActionCard. Confirm runs the same action on the page, marks the request it sends (createConfirmationMark: set on Confirm, taken once by the next request; the route rebuilds it with sanitiseConfirmation and tells the model with confirmationLine) so the server knows this message was confirmed on its card (a typed "yes" never is), and sends the reader's words (confirm) as their message; the reply opens with the record of what was done, worked out on the page and sent as a lead card so it stands still under the thinking line, then the model's sentence. The merchant screen's proposal carries the readout of what it will do as its facts, settles to a one-row "Confirmed" record, and its reply opens with the same ActionCard confirmed with the facts of what it did, which holds Undo; an ActionReadout serves a host whose proposal has no facts of its own. A tool that acts on "every record shown" reads the table as the reply has left it, through execute's shared request object, never as the message found it.
Site header (pattern)
The header is the library's SiteHeader, configured by the website's MegaNav (website/src/components/MegaNav/MegaNav.tsx) with the site's sections from getNavSections(), its logo, the search button, the brand switcher, the background switcher and the theme toggle. Everything a header does (sticking over the progressive blur, the mega menus, folding to the drawer on the room the bar has) is SiteHeader's, and its spec owns it. MegaNav is mounted by each page rather than from the root layout (the footer and the chat are the layout-mounted chrome), and is absent from the chromeless routes.
What the site adds is configuration. The bar is 1440px wide at the site's 78px --ds-site-header-height, which --layout-anchor-clearance in globals.css is set against, and it sits with its drawer on the site's --layer-site-chrome rather than the library's --layer-sticky, since component pages carry sticky demos and tooltips at that layer and above that would otherwise paint over the stuck bar. The site lifts the library's --layer-modal (and --layer-command one step above it) to its own --layer-site-modal, over the bar and the chat, so a dialog, drawer or expanded bento sheet is never capped by the header it opens under. Every control in the action row is at its compact size, and the row ends on Get started, a primary SplitButton whose menu copies the agent starter (/AGENTS.md) and confirms with a toast; the drawer's footer carries its twin. Only Get started folds with the pills: the look switcher, the background switcher and the mode control sit in the bar's actions, so they hold their seat beside the search circle at every width above the chat takeover step (719px) and move to the drawer's footer, above Get started, only below it, where the bar keeps search, chat and menu. The search button is the package's CommandPaletteTrigger (⌘K's visible twin, labelled with the palette's own "Search or ask anything" placeholder) at 288px wide (the room the widest theme's prompt needs), and the field and the theme toggle's three-icon strip are the first things to give way: SiteHeader's condensedActions carry the trigger's iconOnly circle and the toggle's cycle circle (the look and background switchers are circles already), so the pills fold to the menu button only once the condensed row no longer makes room for them. Before the first measurement the condensed set shows below a 1440px bar instead, through a site-header container on .ds-site-header__bar rather than the header root, since a container there would become the containing block for the viewport-fixed blur. initialFold="tablet" sets the server-rendered fold, because the pills stop fitting a little above that step. The folded menu button and the condensed circles wear one fill, hairline and curve, all the package's own, and the drawer's section labels step in to start on the wordmark's first letter. The wordmark carries the system's one weight-700 setting, so the name reads a step heavier than the pills. While the drawer is open, MegaNav sets data-nav-drawer="open" on <html>, so the chat launcher stands down. When the chat docks, the body's padding narrows the bar with the page beneath it, and the measured fold follows.
Site footer (pattern)
The sitemap footer is website chrome, not a library component: SiteFooter, mounted once from the root layout inside a pathname gate (SiteFooterMount), so every page carries it without a per-page import. It skips the chromeless routes (website/src/config/chromeless.ts), the same set the chat panel denies.
A FadeDivider on top — the shared 1px hairline that fades to --color-bg-container-primary-transparent at both ends, the same line the home hero and the DS landing draw — then a brand block beside four columns holding five link groups under a <nav aria-label="Footer"> (Site, Design system, Docs, Foundations, Elsewhere), then a quiet bottom row. Four groups derive from the nav config (getSectionItems, docsSidebarLinks, foundationsSidebarLinks) and website/src/config/social.ts. The Site group (Home and Privacy) is hand-listed in SiteFooter.tsx, because neither page is a nav section; it is the footer's one hand-maintained column. Group names sit in --font-title-body-weight at --font-paragraph-sm-size on --color-text-primary; links are --font-paragraph-sm-* on --color-text-tertiary, hovering to --color-text-primary on --motion-duration-base. External links carry a trailing open_in_new at --icon-size-500. The brand block holds the same brand mark and wordmark the header wears, at the same 24px — with one deliberate divergence: the header wordmark runs the system's lone weight 700 (the Site header pattern above), while the footer's keeps --font-title-body-*, and it is opacity that sets the footer's back (0.5, full on hover and focus), not a smaller or greyer variant — plus the social icon row, which renders whatever SOCIAL_PROFILES in website/src/config/social.ts holds (36px targets, currentColor at --color-text-tertiary) and disappears entirely while that list is empty, as it is today. The bottom row carries two items: the copyright and the author credit, both --font-paragraph-sm-* on --color-text-tertiary, left-clustered so the fixed chat button never covers either, and wrapping to two lines on phones where the same button takes the corner (extra bottom clearance there for the same reason). The credited name and its URL are AUTHOR_NAME/AUTHOR_URL in scripts/brand.mjs, the one home for both. Its link keeps a permanent underline where the column links carry none: a link in a column reads as a link from its position, a name inside a sentence does not.
The brand block is what makes the columns line up, and that is its structural job as much as its brand one. It is exactly --layout-sidebar-width wide, separated from the links by --layout-column-gap — the docs shell's own rail and gutter — so the link grid begins on the same vertical line the page's content does. On a docs page its four columns land pixel-for-pixel on the four content cards above them; on a page with no rail it reads as an ordinary brand block with the columns starting after it. Five equal columns stretched across the whole shell was the version that never sat right: the pages divide that same width differently (three-up on the home page, four-up on the docs pages), so the footer's column edges landed a few pixels off theirs — 2px off the last card, 30–50px off the middle ones, and near-misses read as mistakes where a clear offset reads as a decision. Anchoring the grid to the content line means it either matches exactly or is offset by a whole rail, and the footer stays identical on every page: nothing in it reacts to which shell a page uses.
Five groups fit four columns because Site and Design system share the first one — the pairing that keeps the four columns closest to even. (A link flagged desktopOnly in the nav config hides below 960px, so a column's length can differ by viewport; the footer still reacts to nothing page-specific.) Below 960px the rail is gone from the pages too, so the brand block stops reserving its width and sits above the links, the stacked pair unstacks (display: contents) and all five groups flow as columns — 3 + 2 there, 2 + 2 + 1 on phones. As a body flex child the footer inherits the docked-chat inset and pins to the viewport bottom on short pages via margin-top: auto. Focus rings are the sanctioned --color-action-primary-bg outline; the action colour appears nowhere else in the footer.
Light / Dark Theme Contract
The theme is activated by data-theme="dark" on the HTML root element. The tokens-light.css applies under [data-theme="light"], :root. The tokens-dark.css applies under [data-theme="dark"].
Tokens that invert:
-
--color-bg-page-primary(light floor → near-black floor) -
--color-bg-container-*(light neutral steps → dark neutral steps) -
--color-text-primary(near-black → near-white) -
--color-text-secondary,--color-text-tertiary(dark grays → light grays) -
--color-divider(light semi → dark semi) -
--color-input-bg-primary,--color-input-border-primary,--color-input-text-primary -
--color-action-passive-*(near-transparent fills swap to dark semi-transparent) -
--color-bg-container-inverse/--color-text-on-inverse(the inverse surface — ImageCompare's handle label, the "today" markers — flips from near-black to near-white) -
--color-scrim(0.5 → 0.7 black);--color-control-thumbchains to the on-action ink in both themes, so its per-theme value follows the action plan -
--shadow-floating,--shadow-modal(shadow opacity increases in dark mode) -
--color-action-primary-*— the action family is split by mode by design: each mode pairs its own fill with its own label, because one colour rarely clears contrast against both page floors (see Action / Brand)
Tokens that stay stable:
- Status border colors (
--color-status-*-border) — the same key in both modes (neutralsteps one notch lighter in dark) - Status background/text invert to their dark counterparts for contrast
Rule: Never hardcode color or background-color with a hex value in a component. Always use a semantic token — the theme swap is the only mechanism for dark mode, no manual prefers-color-scheme queries in components. One sanctioned exception: ColorPicker's colour-mixing constants (the white/black overlay gradients, the hue spectrum, the picker-handle white) are colour-space physics no theme token can represent — they are written as hsl() literals, commented in place, and documented in the ColorPicker spec.
Do's and Don'ts
Do
- Use semantic tokens (every
CATEGORY_PREFIXEScategory —--color-*,--radius-*,--gap-*,--padding-*,--border-*,--font-*,--motion-*,--icon-size-*,--control-height-*,--shadow-*,--layer-*) in every component. Never use--primitive-*tokens directly. - Reserve
--color-action-primary-bgfor primary CTA buttons, focus rings, active input states, the checked or on state of a form control, the selected item of a mutually exclusive set, and the hover signal of a surface that is itself a control. The one other sanctioned use is as the lead series colour in charts; nowhere else. - Take heading weights from the type tokens: the display tier for marketing text (a light 300 in the base theme), the heading tier for in-app headings (600). The weights are the theme's; never set one on a page.
- Apply
--radius-pillto all buttons,--radius-300to all inputs and standard containers, and--radius-600to Card/EntityCard navigation tiles. The assignment is the system's shape signature; the values are the theme's. - Map all feedback UI to the five-variant status system (
info/positive/warning/error/neutral) — Badge, Alert, Toast, ProgressBar all share the same semantic tokens. - Prefer
--color-dividerfor rule lines over custom border colors. - Use Material Symbols Rounded for icons, sized with
--icon-sizeset to a scale step (sm20 /md24 /lg32 /xl48). Never setfont-sizeon an icon directly. - Always wrap your app in
ToastProviderbefore callinguseToast().
Don't
- Don't use primitive tokens (
--primitive-neutral-05,--primitive-blue-07, etc.) in components or page styles. - Don't write a
prefers-reduced-motionquery in component CSS — the duration tokens already collapse under it. The one exception runs the other way: that guard is CSS and cannot see a JS-driven loop or timer, so a JS-driven surface has to check the query itself (ShaderField's render loop and Carousel's autoplay are the worked examples) — or ride a library default that does, as the recharts charts do throughisAnimationActive: 'auto'(the chart-set spec owns that contract). - Don't use the action colour decoratively. Using it on text or illustrations dilutes its CTA signal. (Chart series are the one sanctioned exception: the action colour leads the palette.)
- Don't set
displaysize typography (Mega/Display tokens) inside app UI pages — reserve them for the marketing homepage hero only. - Don't bold display sizes at the use site. The display weight is the theme's call (300 in the base theme), and the use site never overrides it.
- Don't invent a fourth surface tone outside page / container-primary / container-secondary / container-tertiary. The neutral ramp is intentionally short.
- Don't add
box-shadowto standard cards and containers. Depth comes from background steps, not shadows. The one exception: interactive Card tiles lift with0 8px 24px rgba(0,0,0,0.3)on hover — a deliberate navigational affordance, not an elevation pattern to copy elsewhere. Floating surfaces and modals use--shadow-floating/--shadow-modal. - Don't hardcode colors. Every color must be a semantic token so dark mode works without extra code.
- Don't break the five-status system by adding a sixth variant (e.g. "brand") to Badge or Alert. Use
neutraland customize within the component page if needed. - Don't disable the
data-themeswap on any subtree — all components must participate in theme switching.
Responsive Behavior
Breakpoints
Breakpoints have one home: src/tokens/breakpoints.ts, published as rift-ds/tokens/breakpoints, with the site's own families in website/src/config/breakpoints.ts. Each value is the last width of the narrower side, so max-width: N and min-width: N + 1 both name it, and JavaScript builds its queries from the module (mediaBelow, mediaAbove) rather than restating a number. Each module also describes its steps as data (BREAKPOINTS, SITE_BREAKPOINTS), which /foundations/layout renders as its Breakpoints section. The canonical layout set is 1279 / 1151 / 959 / 768 / 600, all max-width. The rail widths and column gap live as --layout-* custom properties in globals.css, so the mid-breakpoint narrowing applies to every page at once (/foundations/layout draws the shell from the declared values and lists every other --layout-* property):
- ≥ 1280px — full shell: 291px left nav (
--layout-sidebar-width), 320px right rail (--layout-rail-width), 60px column gaps (--layout-column-gap). - ≤ 1279px (mid) — both rails narrow (nav 240px, rail 280px) and the column gap tightens to 40px, keeping the center column at a readable measure.
- ≤ 1151px — the right details rail stacks below the main content (per-page media query on the
resumeLayout/updatesLayoutflex row); the left nav stays. - ≤ 959px (tablet) — the left nav hides (its links move into the header drawer's section accordions) and pages collapse to a single column.
- ≤ 768px (mobile) — the token layer's only breakpoint: display-tier typography and section-rhythm spacing collapse (see Typography → Hierarchy and Spacing → Gap Scale).
- ≤ 600px (phone) — body side gutters tighten; dense grids switch to horizontal scroll inside their own blocks.
Beside the layout set, the module names the steps components switch at: 480px (sheet), where the modal overlays drop their floating margin, and the chat widget's three thresholds: the takeover at 719px, the dock above 1439px, and the short-viewport height at 619px (see ChatWidget). The site's own families are the template screens' 1080px and 760px steps and the immersive stages' 1099px compact step.
A threshold outside these sets is allowed only when it is content-driven (a bespoke grid that breaks at its own natural width) or deliberately sits on a step's other edge, and it says so in place with a /* ds-allow(breakpoint): <reason> */ directive directly above the query. scripts/validate-breakpoints.mjs fails the build on any @media width, or media-query string in TypeScript, that is neither in the set nor sanctioned, and holds the layout set stated above to the module.
The thresholds stay raw numbers in the stylesheets, and that is settled rather than pending: CSS custom properties cannot drive @media conditions, and a preprocessor dependency is not worth it for a handful of documented literals. The module is the number's home and the validator is what keeps the literals honest; the rail widths and gutters they switch are tokenized (--layout-*).
Typography Collapse
- Handled by the token layer at ≤768px — Display 2 64px → 40px, Sub Display 30px → 24px, Mega/Display 1 proportional. No per-page overrides.
- In-app headings (H1–H3, 30–22px) hold at every viewport; never reduce H3 below 20px.
Layout Collapse
AppLayoutsidebar collapses to off-canvas/drawer on mobile.- Component index grids reflow from 3-up (desktop) → 2-up (tablet) → 1-up (mobile).
SelectionCardgroups reflow from side-by-side to stacked on narrow viewports.- Table components allow horizontal scroll on mobile rather than reflowing columns.
Touch Targets
- All interactive elements minimum 44 × 44px effective touch area (WCAG 2.5.5).
- Button default height: ~36px from padding + line-height. Add
min-height: 44pxif used as standalone tappable button on mobile.
Iteration Guide
- Adding a new component variant — Add a modifier class (e.g.
.ds-badge--brand) and reference only--color-*semantic tokens. Add corresponding light and dark values totokens-light.cssandtokens-dark.cssif a new semantic meaning is needed. - Adding a new status — Add
--color-status-{name}-bg,--color-status-{name}-border,--color-status-{name}-textto both theme files. Then add the variant to Badge, Alert, Toast, and any other status-bearing components. - Adding a new spacing step — Add to
tokens-primitives.cssfirst, then alias intokens-light.cssonly: spacing is not theme-split, andtokens-dark.csscarries nothing but colour and shadow. - New typography style — Add to
tokens-typography.cssfollowing the--font-{name}-{property}naming pattern. - Reference a component token — Always use the full CSS variable, e.g.
var(--font-heading-1-size). Never inline the resolved value. - Storybook stories — Each component must have a
.stories.tsxfile with a Default story and one story per meaningful variant. Usedata-themeStorybook theme toggle to verify dark mode. - Before shipping — Run
npm run verify, the full local quality gate — CLAUDE.md's CI & Local Verify section owns the step list; every Storybook story renders in headless Chromium as a smoke test, with any storyplayfunction asserting behavior on top. The same checks run automatically in CI (.github/workflows/ci.yml) on every push and PR, including a drift guard that fails if generated documentation is stale.
Known Gaps
- Figma parity: the system is drawn as a Figma kit (Rift DS) with a frame per component, and every docs page links its frame through
PageLinksfromwebsite/src/data/figma-nodes.json, behindSHOW_FIGMA_LINKSinwebsite/src/config/social.ts. The DTCG files under/schema/tokens/are an automated export a Figma plugin can import, but nothing pushes them into the Figma file, so keeping it in sync with the coded tokens is still manual.
Three former entries left this list as decisions rather than work: JS-driven timings now share one home (src/tokens/motion.ts — see Motion → Migration status), the raw breakpoint literals are settled as raw (see Responsive Behavior → Breakpoints), and when to validate, with the form's state, is permanently the consumer's form layer, not the system's; the library draws the result (see the Input spec).