Foundations
The tokens every component is built from: color roles and their contrast pairs, the type scale, spacing and the layout rhythm, motion, and the page-level breakpoints.
Design tokens
How color, typography, spacing and size are defined once and delivered to every platform.
Tokens are stored in the W3C Design Tokens (DTCG) format under tokens/themes/<id>/ and built with Style Dictionary into CSS custom properties, JavaScript modules, React Native objects, and flat JSON under packages/tokens/dist/<id>/. Every value a component uses comes from a token; no component file contains a raw color, size, or font.
Token files are derived, not authored. tools/theme.ts generates them from the decisions in each theme doc; edit the theme, not the JSON.
Two layers per theme
Base (base.json) holds raw materials: palettes as numbered ramps (color.palette.brand.500), the type scale, spacing, radius, and target sizes. Components must never reference these directly.
Semantic (light.json, dark.json) gives materials meaning. The mode files have identical token names and different values, so a mode switch is a value change, not a code change. On the web, the theme is selected by importing its CSS and the mode by data-mode="dark" on the root element.
Naming
Semantic names describe role, not appearance: color.foreground not color.gray800. A default leaf is dropped from output names so the common case reads naturally:
| Token path | CSS | JS / RN |
|---|---|---|
color.foreground.default | --color-foreground | colorForeground |
color.foreground.strong | --color-foreground-strong | colorForegroundStrong |
color.action.primary.background | --color-action-primary-background | colorActionPrimaryBackground |
space.md | --space-md | spaceMd (12 in RN, 12px on web) |
The semantic vocabulary
color.foreground(.strong,.muted,.onAction,.danger) — text and iconscolor.background(.subtle,.strong) — surfaces, in increasing elevationcolor.border(.strong,.focus) — dividers and the focus ringcolor.action.<variant>.{background, backgroundHover, foreground}— interactive fills;<variant>isprimary,secondary,ghost,dangercolor.link.{default, hover, visited}— link text, all three at 4.5:1 on the page in both modescolor.control.{background, border, selectedBackground, selectedForeground, trackOff}— checkboxes, radios and switches; the selected fill is chosen per mode so it meets 3:1 against both the page and the control surface while its ink meets 4.5:1 (dark mode lands on a lighter brand step with dark ink)color.status.<tone>.{background, foreground, border, icon}— alerts and meters;<tone>isinfo,success,warning,danger;foregroundis the darkest passing text on the tinted surface,iconthe step that meets 3:1 on the pageopacity.disabled,motion.duration.{fast, base, loop},motion.easing.{standard, exit}— derived from the theme’smotiondecisioncolor.overlay.{scrim, surface}— the modal backdrop (a translucent ink) and the surface overlays sit on (the page background in light mode, one step lighter than the page in dark mode)color.inverse.{surface, foreground, muted, link, focus}andcolor.inverse.status.<tone>— the flipped surface Tooltip and Toast use (dark on light, light on dark), every step chosen for contrast against itshadow.{raised, overlay}— DTCG shadow objects scaled by the theme’selevationdecision (flatremoves them); CSS box-shadow strings on web, iOS shadow props plus Android elevation on React Nativelayer.{base, raised, dropdown, sheet, dialog, toast}— stacking order as plain numbers, identical on every platformfont.family.{body, heading, mono},font.weight.*,font.size.{xs…4xl},font.lineHeight.*space.{0…20}on a 4px grid, withspace.{sm, md, lg}as the aliases components use inside themselveslayout.gap.{none, tight, normal, loose, section},layout.inset.{none…xl},layout.section.{sm, md, lg},layout.gutter.{narrow, default, wide},layout.maxWidth.{prose, content, page}— rhythm between components and at page level, scaled by density and the theme’slayout.rhythm; see Layout and rhythmradius.{none, sm, md, lg, full},border.width.{thin, focus}size.target.min(24px, WCAG 2.5.8 AA) andsize.target.comfortable(44px, AAA and platform guidelines)
Accessibility is checked at build time
Component docs declare which foreground/background token pairs they render together. tools/check_contrast.ts resolves those pairs for every enum value and every theme and fails the build below the declared WCAG level. Changing a palette value that breaks a button in dark mode is caught before it ships.
Building
pnpm themes # tools/theme.ts derives tokens/themes/<id>/*.json, then tokens/build.mjs builds the dist
pnpm tokens # Style Dictionary → packages/tokens/dist/<id>/{css,js,rn,json} + names.{js,d.ts}Layout and rhythm
The tokens that decide space between components and at page level — gutters, sections, gaps, insets and measure — derived from the theme's density and rhythm, and the rules for using them.
Spacing inside a component is the component’s business: Button binds its padding to space.sm and space.{size}, Alert binds padding: space.md, and every platform renders the same numbers. Spacing between components is nobody’s business unless the system makes it someone’s — and when it is nobody’s, screens drift: one form has 12px between fields and the next has 20, one page hugs the viewport edge and another floats. The layout token group is where the system owns that rhythm, and the foundational components (Stack, Box, Card, Container) are the only way it is meant to be applied.
The tokens
All of these except breakpoint are derived by tools/theme.ts from two theme decisions — density (which already scales the space grid) and layout.rhythm (tight / normal / loose, which scales the between-component steps a second time) — plus layout.contentWidth. Calm & precise, at comfortable density and normal rhythm, resolves to the values in the right column.
| Token | Use | Calm & precise |
|---|---|---|
layout.gap.none / tight / normal / loose / section | Gap between siblings in a Stack, in either direction. tight for related controls (a button pair, a checkbox and its helper), normal for fields in a form, loose between groups, section between page sections. | 0 / 4 / 8 / 16 / 32 |
layout.inset.none / sm / md / lg / xl | Padding a surface (Box, Card) puts around its content. sm for dense list rows, md for cards and alerts, lg for page-level panels, xl for hero sections. | 0 / 8 / 12 / 16 / 32 |
layout.section.sm / md / lg | Vertical space between page-level sections: between a page heading and its content, between a Landmark and the next. | 32 / 48 / 64 |
layout.gutter.narrow / default / wide | Horizontal page padding at narrow, default and wide viewports. Container applies it. | 16 / 24 / 32 |
layout.maxWidth.prose / content / page | Column widths. prose is a 65-character measure at the body size; content is the theme’s contentWidth; page is four-thirds of it. | 572 / 960 / 1280 |
layout.breakpoint.sm / md / lg | Viewport widths at which page chrome changes shape — the header’s nav-row-vs-hamburger switch, the docs sidebar-vs-Select swap. Fixed pixels, the same in every theme. | 640 / 768 / 1024 |
The names are deliberately semantic. A component never binds space.4 for a gap between siblings; it binds layout.gap.loose, so a theme that says rhythm: loose opens up every screen at once and a compact tool’s rhythm: tight closes them, without either touching a component.
The rules
Siblings are spaced by their parent. Nothing carries a margin. A Stack sets the gap between its children; a Box or Card pads its own content; a Container pads the page. If two things need space between them, the answer is always “put them in a Stack with the right gap”, never “give the second one a margin”. This is why generated components have no margin bindings at all, and why the literal gate treats a margin literal as an error.
Choose gap by relationship, not by pixels. The four presets map to four relationships: parts of one control (tight), items in one group (normal), groups in one region (loose), regions on one page (section). Reaching for a numeric space.* token between components means the relationship is unclear; name it and the preset follows.
Surfaces inset, layouts gap. Box and Card own the padding around their content with inset; Stack owns the gap between things. A Card that contains a Stack is the normal shape: the Card decides how far content sits from its edge, the Stack decides how far the pieces sit from each other. Neither needs to know the other’s value.
Pages have a gutter and a measure. Container applies the gutter at the viewport edge and caps width at content (or page for full-bleed layouts, prose for reading). Body text longer than a few lines lives inside a prose column; nothing else caps text width.
Breakpoints are for page chrome, not components. layout.breakpoint.* is the one token group in this system that a component in packages/*/src must never bind. A component does not know what viewport it is in — it knows how much room its container gave it, so its responsiveness is container-query-driven (Splitter’s collapse rule, measured by ResizeObserver, is the precedent) and a viewport breakpoint would make it lie whenever it is rendered in a sidebar or a split pane. Page-level chrome is the opposite case: the header choosing a nav row over a hamburger, and the docs layout choosing a sidebar over a Select, are decisions about the window, so they read these tokens. Consumed by the website app (apps/website) only.
Sections, not dividers. Vertical rhythm between regions comes from section spacing, not from horizontal rules; a Divider (planned) is for lists, not for page structure.
Across platforms
The values are the same everywhere because they are the same tokens: --layout-gap-normal in CSS, layoutGapNormal in the React Native token object, and on native the number is device-independent points, which is what CSS px are on the web. What differs is only the container: flex gap on web and on React Native ≥ 0.71, which every generated Stack uses, so no platform falls back to margins.
How a theme changes it
Three frontmatter decisions and nothing else:
theme:
density: comfortable # scales the space grid (0.75 / 1 / 1.25)
layout:
rhythm: normal # scales gaps and sections again (0.75 / 1 / 1.5)
contentWidth: 960 # px; page = 4/3, prose = 65ch at the body size
A dense admin tool might choose density: compact, rhythm: tight; a marketing site density: roomy, rhythm: loose, contentWidth: 1120. The components do not change; the rhythm does.
layout.breakpoint.* is not on that list, and neither density nor rhythm moves it: 640 / 768 / 1024 describe devices, not taste, and a compact theme still has to decide “does a sidebar fit here?” at the same width a roomy one does. They are written once in tools/theme.ts and every theme gets the same three, so moving them is a change to the system rather than a theme decision — and overrides is not the way to do it: that escape hatch is keyed per mode (overrides.light / overrides.dark), and a breakpoint is one value for both.
Reading a breakpoint
Almost always in CSS, where the token’s value is inlined at build time:
@media (min-width: 768px) { /* layout.breakpoint.md */ }
A custom property cannot appear in a media condition, so the number is written out. That is not a hole in the literal gate: pnpm lint:literals scans packages/{react,lit,rn}/src and packages/swiftui/Sources/DesignSchema, and this token belongs to apps/website, outside that tree. Inside it the gate still raises every media-query number — which is why Container’s @media bounds carry an explicit literal-ok: breakpoint from layout.maxWidth.* — and a viewport breakpoint has no such excuse to offer, because no component should be switching on the window in the first place.
The value is also in packages/tokens/dist/<theme>/css/tokens.css as --layout-breakpoint-md for anything that can take a custom property, and it is importable for the rare JS-side check — an onResize fallback, or a matchMedia string built at runtime:
import { layoutBreakpointMd } from '@design-schema/tokens/calm-precise/light'; // '768px'
const wide = matchMedia(`(min-width: ${layoutBreakpointMd})`);
resolveToken from @design-schema/tokens reaches the same value from the dotted name (resolveToken(tokens, 'layout.breakpoint.md') over a namespace import), which is the form to use when the ref is a variable. React Native has no viewport chrome to switch and gets the token as a plain number (768), should a future app shell need it.
Styling and overrides
How generated components are styled, the three layers at which a look can change, and the one per-instance override contract that works identically on web, web components and React Native.
Every visual value in a generated component is a token reference. The question this page answers is what happens when someone needs a component to look different in one place — a wider button in a hero, a card with more padding — without breaking the promise that the three platforms match and that accessibility is checked at build time. The answer is a small, typed contract, the same on every platform, plus one honest escape hatch on the web.
Three layers
Layer 1 — the theme. Global, and the one most people should reach for. A theme doc changes the seed, scale, radius, density, rhythm; every component follows. If a brand wants tighter buttons everywhere, that is density, not an override.
Layer 2 — per-instance overrides, in tokens. Every component accepts an overrides prop keyed by its style bindings and valued by token names. <Button overrides={{ paddingInline: 'space.lg', radius: 'radius.full' }} /> works the same as <ds-button .overrides=${...}> and the same in React Native. The keys are the binding names from the component’s schema (the ones in its styles table), the values are TokenRef — the string-literal union of every token name, exported by @design-schema/tokens — so a typo or a pixel value is a type error, not a runtime surprise. This is the only per-instance styling surface on React Native (there is no style prop), which is what keeps a screen from drifting off-system.
Layer 3 — the web escape hatch. On web and in web components, every binding is also a CSS custom property on the component root, named --ds-<component>-<binding>. Product CSS may set them: .hero ds-button { --ds-button-padding-inline: var(--space-lg) }. That is sanctioned and documented; it is also visible to code review as exactly what it is. Setting the hook to a literal (24px) works in the browser and fails the literal gate in this repository, which is the intended relationship: the escape hatch exists, and using it off-system is a choice you make in the open.
What cannot be overridden
Bindings that carry an accessibility guarantee are locked: any color that appears in the component’s declared contrast pairs, the focus ring color and width, and minimum target sizes. tools/parse.ts computes this from the schema, so it cannot be forgotten, and a doc can lock more with locked: true on a binding. Locked bindings are absent from the overrides type and ignored if passed, but on web and in web components they keep their --ds-<component>-<binding> hook: locked closes the override API, not the styling hook, so a locked binding is still themeable from product CSS and still renameable to a brand prefix by the naming codemod. (A binding forwarded to a composed child’s overrides has no hook of its own — the child carries it.) The effect is that an override can change how much room a button has, but not whether its text is readable or its focus visible. A team that needs a different button color makes a variant in the schema, where the contrast checker sees it.
Overrides change values, never presence
An override changes which token a binding uses; props decide whether the part exists. surface: none renders no background, so overrides.background is ignored; border: false renders no border, so overrides.border and overrides.borderWidth are ignored; radius: none ignores overrides.radius. A consumer who wants a bordered box asks for one (border) and then may recolor it. This keeps the enum the single statement of what a component shows, and keeps an override from smuggling in a part the schema said was absent. Generators apply overrides only where the binding is in effect.
Composites forward overrides
A composite never styles a child, but it may pass an override through: Fieldset hands overrides.fieldsGap to its Stack as overrides.gap; Divider hands labelSize to its Text as fontSize; Alert colors its Icon with overrides.color. The child resolves the token itself, so nothing is resolved twice and the child’s locked bindings stay locked. This is the only way a composite influences a child’s look, and it is why every component exposes overrides even when no consumer would set them directly.
How the generated code is shaped
On web and Lit, each binding produces two declarations: the hook with its default, and the rule that reads the hook.
.ds-button {
--ds-button-padding-inline: var(--space-md);
padding-inline: var(--ds-button-padding-inline);
}
.ds-button--primary { --ds-button-background: var(--color-action-primary-background); }
Interpolated bindings (color.action.{variant}.background) set the hook per variant class; the rule reads the hook once. The overrides prop writes the hook inline as var(--<token>). In Lit the hook lives on :host, and because a document stylesheet’s rule on the host element beats a :host rule inside the shadow root, external CSS overrides work without ::part — and no ::part is exposed for styling, so the surface stays this one.
On React Native there is no cascade, so the component resolves overrides through the theme: resolveToken(t, 'space.lg') returns the number, and it replaces the binding’s default in the style object. The same TokenRef type constrains it.
What this means for the generator
The rules above are in the platform templates, so every regenerated component carries the contract, and tools/parse.ts writes the overridable and locked binding lists into each prompt. Components generated before this decision (Tiers 0 and 1) do not have the hooks yet; they gain them the next time their doc changes and they regenerate, or in one deliberate pass. tools/check_hooks.ts (pnpm gates:hooks, and the hooks gate on web and lit) names every locked binding still missing its hook, so the remaining debt is countable rather than remembered.
What it does not do
It does not let a consumer restyle a component’s children: Alert cannot recolor the Button it contains, and neither can product code through Alert. The rule that composites never restyle a child stands; the override contract is per component, applied to that component’s own bindings. And it does not let a design tool inject pixels: a designer’s decision enters as a token change (a theme edit) or a binding change (a doc edit), which is what keeps designs and code from becoming two truths.
Focus management
The rules for where keyboard focus goes — into an overlay, around inside it, back out again, and along a list — and the two primitives every component uses to obey them, so focus is never trapped by accident or lost by design.
Keyboard focus is the one piece of state a design system cannot leave to each component. A dialog that traps focus but never releases it is a WCAG failure; a menu whose items are all tab stops is a usability failure; a toast that steals focus is both. Every rule below exists in a component doc somewhere; this page is the single statement of them, and the two primitives — FocusScope for confinement, roving tabindex for composites — are how a generated component gets them right without re-deriving them.
The four movements
Into. When an overlay opens, focus moves into it (Dialog: the first control, the title for reading dialogs; AlertDialog: the safe action; Menu: the first item; BottomSheet: first control or title). Nothing else ever moves focus on open: a Toast or an Alert announces and leaves focus where it is. The element that had focus is remembered.
Around. Inside a modal scope (Dialog, AlertDialog, BottomSheet, ActionSheet on phones), Tab and Shift+Tab cycle among the scope’s focusable elements and wrap at the ends; nothing outside is reachable by keyboard, pointer, or assistive technology because the rest of the page is inert. Inside a composite (Menu, RadioGroup, the planned Tabs and Listbox), the whole widget is one tab stop and arrow keys move a single roving tabindex among the items; Tab leaves the widget.
Out. Escape always exists as an exit from any scope, even one marked non-dismissible (it reports and lets the consumer decide). Activating a menu item, choosing an action, or completing a dialog closes the scope. Tab out of a non-modal composite closes it.
Back. When a scope closes, focus returns to the element that opened it — or, if that element is gone, to the next focusable element after where it was. Composites return focus to their trigger. A component that removes the focused element (a dismissed Alert, a collapsed Disclosure) moves focus to the next sensible place first, so focus never falls to body.
Modal scope vs. composite: two different confinements
They look similar and are opposite. A modal scope confines Tab: the user can reach everything inside and nothing outside. A composite confines arrows: the user reaches one thing (the widget) with Tab and moves inside it with arrows. Mixing them is the classic bug — a menu that traps Tab, a dialog whose fields need arrow keys. The schema tells them apart: focus-trap + inert-background in a11y.requires means a modal scope; roving-tabindex + arrow-navigation means a composite. A component may be both (ActionSheet on phones: a modal sheet containing a composite menu), in which case Tab cycles within the sheet and arrows move within the list.
FocusScope
The confinement primitive. It is a component (see FocusScope) so it can be composed and tested once, but consumers rarely render it directly; Dialog, AlertDialog, BottomSheet and ActionSheet declare it in their composition. It does four things: moves focus in on mount according to autoFocus; wraps Tab and Shift+Tab within its focusable descendants when trapped; keeps focus inside if something outside tries to take it (a script calling focus(), a browser quirk); and restores focus on unmount when restoreFocus. It does not make the background inert — that is the overlay’s job (native <dialog> does it; React Native’s accessibilityViewIsModal does it) — and it does not handle Escape, which belongs to the component that decides what Escape means.
Nesting is allowed and ordered: the innermost mounted scope owns Tab. Opening a Menu inside a Dialog does not disable the Dialog’s scope; the Menu’s roving tabindex works within it, and closing the Menu returns focus to its trigger, which is inside the Dialog.
Roving tabindex
The composite primitive, implemented per component rather than as a wrapper because the item set and orientation are the component’s own: one item has tabindex="0" (the selected one, or the first), the rest -1; arrow keys move the 0 and call focus(); Home and End jump; typeahead moves by label where the pattern calls for it; disabled items are skipped. On the web the focused item is the highlighted item — aria-activedescendant is not used, because moving real focus is what screen readers follow most reliably across the shadow boundary. On React Native, composites have no roving tabindex; every item is its own accessibility stop, which is the platform convention and is recorded as such in each doc.
What the gates check
The keyboard block in a component doc is this page as data: { keys: [Tab], from: last, expect: focus-wraps-to-first }. tools/keyboard_tests.ts turns every rule with an expect into a Playwright test against the component’s Keyboard story, so a scope that stops wrapping, a menu whose Escape no longer restores focus, or a dialog that lets Tab escape fails the build. The axe gate catches the rest of what is checkable without interaction: names, roles, contrast.
Platform notes
On the web, a native <dialog> opened with showModal() provides the inert background and the top layer, but browsers still let Tab reach the address bar from the last element, so FocusScope’s wrap is real work, not belt-and-braces. Shadow DOM hides focusable elements from a naive querySelectorAll; FocusScope walks shadow roots and slotted nodes. React Native has no Tab order to confine; FocusScope there means accessibilityViewIsModal on the surface, setAccessibilityFocus on open, and restoring focus to the opener’s ref on close.