Spec sheet
Every token value and every component style binding for the calm-precise theme, resolved in light and dark, with contrast ratios for the color pairs that matter.
Generated from the built tokens of the calm-precise theme and generated/components.json. Every value is the resolved light and dark value a component receives on every platform. Other built themes: warm-friendly, warm-sleek — regenerate for one of them with pnpm spec-sheet --theme <id>.
Spacing scale
Bars are drawn at the token’s pixel value (wide values clip to the column). Components bind to the numbered steps or the sm/md/lg aliases; layout rhythm below is what goes between components.
Steps
Aliases
Layout rhythm
Gaps
Sections
Insets
Gutters
Max widths
Type scale
Each size is rendered at its own value in font.family.body, at font.lineHeight.normal (1.5). Line-height tokens: tight 1.2, normal 1.5, loose 1.7.
font.size.xs 11px · line height: font.lineHeight.tight / font.lineHeight.normal / font.lineHeight.loosefont.size.sm 13px · line height: font.lineHeight.tight / font.lineHeight.normal / font.lineHeight.loosefont.size.md 16px · line height: font.lineHeight.tight / font.lineHeight.normal / font.lineHeight.loosefont.size.lg 19px · line height: font.lineHeight.tight / font.lineHeight.normal / font.lineHeight.loosefont.size.xl 23px · line height: font.lineHeight.tight / font.lineHeight.normal / font.lineHeight.loosefont.size.2xl 28px · line height: font.lineHeight.tight / font.lineHeight.normal / font.lineHeight.loosefont.size.3xl 33px · line height: font.lineHeight.tight / font.lineHeight.normal / font.lineHeight.loosefont.size.4xl 40px · line height: font.lineHeight.tight / font.lineHeight.normal / font.lineHeight.looseFamilies and weights
| Token | Value |
|---|---|
font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif |
font.family.heading | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif |
font.family.mono | ui-monospace, SFMono-Regular, Menlo, Consolas, monospace |
font.weight.regular | 400 |
font.weight.medium | 500 |
font.weight.semibold | 600 |
font.weight.bold | 700 |
font.lineHeight.tight | 1.2 |
font.lineHeight.normal | 1.5 |
font.lineHeight.loose | 1.7 |
Color roles
Semantic colors only (the color.palette.* ramps are raw material components never reference). The contrast column checks text pairs against WCAG AA 4.5:1 and icon/border/focus pairs against 3.0:1; a transparent background is checked against color.background.
| Token | Light | Dark | Contrast |
|---|---|---|---|
color.foreground | #2a2b2f | #ecedee | on color.background (needs 4.5): light 14.14:1 AA pass; dark 15.14:1 AA pass |
color.foreground.strong | #000000 | #ffffff | on color.background (needs 4.5): light 21.00:1 AA pass; dark 17.75:1 AA pass |
color.foreground.muted | #56585c | #bcbec1 | on color.background (needs 4.5): light 7.13:1 AA pass; dark 9.53:1 AA pass |
color.foreground.onAction | #ffffff | #ffffff | — |
color.foreground.danger | #b51d26 | #ff958d | on color.background (needs 4.5): light 6.63:1 AA pass; dark 8.40:1 AA pass |
color.background | #ffffff | #17181b | — |
color.background.subtle | #f6f7f8 | #2a2b2f | — |
color.background.strong | #ecedee | #414246 | — |
color.border | #d9dbdd | #414246 | — |
color.border.strong | #939599 | #707176 | — |
color.border.focus | #496ded | #99b5ff | on color.background (needs 3.0): light 4.47:1 AA pass; dark 8.80:1 AA pass |
color.border.danger | #b51d26 | #ff958d | — |
color.link | #3553d2 | #99b5ff | on color.background (needs 4.5): light 6.33:1 AA pass; dark 8.80:1 AA pass |
color.link.hover | #243ab3 | #c2d4ff | — |
color.link.visited | #162391 | #dee8ff | — |
color.control.background | #ffffff | #2a2b2f | — |
color.control.border | #939599 | #707176 | — |
color.control.selectedBackground | #3553d2 | #496ded | — |
color.control.selectedForeground | #ffffff | #000000 | on color.control.selectedBackground (needs 4.5): light 6.33:1 AA pass; dark 4.69:1 AA pass |
color.control.trackOff | #939599 | #707176 | — |
color.status.info.background | #f1f5ff | #101d53 | — |
color.status.info.foreground | #405bb6 | #c2d4ff | on color.status.info.background (needs 4.5): light 5.68:1 AA pass; dark 10.70:1 AA pass |
color.status.info.border | #99b5ff | #2d4597 | on color.status.info.background (needs 3.0): light 1.85:1 AA fail; dark 1.82:1 AA fail |
color.status.info.icon | #405bb6 | #99b5ff | on color.status.info.background (needs 3.0): light 5.68:1 AA pass; dark 7.87:1 AA pass |
color.status.success.background | #e0ffe0 | #002e05 | — |
color.status.success.foreground | #00791d | #a0eaa2 | on color.status.success.background (needs 4.5): light 5.21:1 AA pass; dark 10.61:1 AA pass |
color.status.success.border | #76d07a | #005e14 | on color.status.success.background (needs 3.0): light 1.77:1 AA fail; dark 1.87:1 AA fail |
color.status.success.icon | #00791d | #76d07a | on color.status.success.background (needs 3.0): light 5.21:1 AA pass; dark 7.95:1 AA pass |
color.status.warning.background | #fff4e1 | #322000 | — |
color.status.warning.foreground | #825b00 | #fccc78 | on color.status.warning.background (needs 4.5): light 5.60:1 AA pass; dark 10.47:1 AA pass |
color.status.warning.border | #e6ac3f | #664700 | on color.status.warning.background (needs 3.0): light 1.87:1 AA fail; dark 1.84:1 AA fail |
color.status.warning.icon | #825b00 | #e6ac3f | on color.status.warning.background (needs 3.0): light 5.60:1 AA pass; dark 7.70:1 AA pass |
color.status.danger.background | #fff2f0 | #4c0005 | — |
color.status.danger.foreground | #b51d26 | #ffc3bd | on color.status.danger.background (needs 4.5): light 6.06:1 AA pass; dark 10.53:1 AA pass |
color.status.danger.border | #ff958d | #940014 | on color.status.danger.background (needs 3.0): light 1.93:1 AA fail; dark 1.73:1 AA fail |
color.status.danger.icon | #b51d26 | #ff958d | on color.status.danger.background (needs 3.0): light 6.06:1 AA pass; dark 7.59:1 AA pass |
color.overlay.scrim | #00000066 | #00000099 | — |
color.overlay.surface | #ffffff | #2a2b2f | — |
color.inverse.surface | #17181b | #ecedee | — |
color.inverse.foreground | #f6f7f8 | #17181b | on color.inverse.surface (needs 4.5): light 16.55:1 AA pass; dark 15.14:1 AA pass |
color.inverse.muted | #bcbec1 | #56585c | on color.inverse.surface (needs 4.5): light 9.53:1 AA pass; dark 6.08:1 AA pass |
color.inverse.link | #99b5ff | #243ab3 | on color.inverse.surface (needs 4.5): light 8.80:1 AA pass; dark 7.68:1 AA pass |
color.inverse.focus | #99b5ff | #243ab3 | on color.inverse.surface (needs 3.0): light 8.80:1 AA pass; dark 7.68:1 AA pass |
color.inverse.status.neutral | #f6f7f8 | #17181b | on color.inverse.surface (needs 3.0): light 16.55:1 AA pass; dark 15.14:1 AA pass |
color.inverse.status.info | #99b5ff | #405bb6 | on color.inverse.surface (needs 3.0): light 8.80:1 AA pass; dark 5.28:1 AA pass |
color.inverse.status.success | #76d07a | #00791d | on color.inverse.surface (needs 3.0): light 9.36:1 AA pass; dark 4.77:1 AA pass |
color.inverse.status.warning | #e6ac3f | #825b00 | on color.inverse.surface (needs 3.0): light 8.74:1 AA pass; dark 5.20:1 AA pass |
color.inverse.status.danger | #ff958d | #b51d26 | on color.inverse.surface (needs 3.0): light 8.40:1 AA pass; dark 5.65:1 AA pass |
color.action.primary.background | #3553d2 | #3553d2 | — |
color.action.primary.backgroundHover | #243ab3 | #496ded | — |
color.action.primary.foreground | #ffffff | #ffffff | on color.action.primary.background (needs 4.5): light 6.33:1 AA pass; dark 6.33:1 AA pass |
color.action.secondary.background | #ecedee | #414246 | — |
color.action.secondary.backgroundHover | #d9dbdd | #56585c | — |
color.action.secondary.foreground | #000000 | #ffffff | on color.action.secondary.background (needs 4.5): light 17.91:1 AA pass; dark 10.04:1 AA pass |
color.action.ghost.background | transparent | transparent | — |
color.action.ghost.backgroundHover | #f6f7f8 | #2a2b2f | — |
color.action.ghost.foreground | #3553d2 | #99b5ff | on color.action.ghost.background (needs 4.5): light 6.33:1 AA pass; dark 8.80:1 AA pass |
color.action.danger.background | #b51d26 | #d33a3c | — |
color.action.danger.backgroundHover | #940014 | #b51d26 | — |
color.action.danger.foreground | #ffffff | #ffffff | on color.action.danger.background (needs 4.5): light 6.63:1 AA pass; dark 4.73:1 AA pass |
Components
Every style binding per component, in generated/components.json order. Bindings with a {prop} slot are listed once per enum value. Locked bindings cannot be overridden per instance.
Accordion
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
divider | color.border | #d9dbdd | #414246 | no | Passed to each composed Divider as its color override (always, even when it equals Divider’s default); Accordion writes no divider rule of its own. The Divider keeps its default spacing: none: itemGap is the only space around it, and its default semantic: false, so the dividers are decorative and the accordion reads as a list of headings. Like dividerWidth and fontFamily, this is an overridable hook even though it names no part: all seven bindings are overridable, whether or not they carry one. |
dividerWidth | border.width.thin | 1px | 1px | no | Passed to each composed Divider as its thickness override. |
itemGap | layout.gap.none | 0px | 0px | no | part list: Items touch; the divider separates them. Applied as the gap of the list container (flex gap on web and Lit, the gap style on React Native), never on the Disclosure itself, so when divided the gap also falls between each item and its Divider — intended: the divider sits centred in the space between items. At the default layout.gap.none that space is zero, so the centring is only observable through an itemGap override. |
triggerPaddingBlock | space.md | 12px | 12px | no | part trigger: Roomier than a lone Disclosure, since accordion triggers are section headings. Forwarded to Disclosure’s triggerPaddingBlock, like fontFamily, triggerFontSize and triggerFontWeight (see composition); the accordion does not style the trigger itself. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part trigger: Forwarded to Disclosure’s triggerFontFamily. Like divider and dividerWidth, it is an overridable hook even though Accordion writes no rule for it. |
triggerFontSize | font.size.md | 16px | 16px | no | part trigger. |
triggerFontWeight | font.weight.medium | 500 | 500 | no | part trigger. |
minTarget | size.target.min | 24px | 24px | yes | The composed Disclosure’s own minimum, applied by Disclosure; Accordion adds no rule for it (nor for focusRing/focusRingWidth). The triggerPaddingBlock forward makes the row roomier but does not guarantee a 44px target. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | Applied by the composed Disclosure’s trigger; Accordion adds no rule. |
focusRingWidth | border.width.focus | 2px | 2px | yes | Applied by the composed Disclosure’s trigger; Accordion adds no rule. |
ActionSheet
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
scrim | color.overlay.scrim | #00000066 | #00000099 | no | part scrim. |
surface | color.overlay.surface | #ffffff | #2a2b2f | yes | part surface. |
shadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | part surface. |
radius | radius.lg | 6px | 6px | no | part surface: Top corners only on the sheet. |
itemPaddingBlock | space.sm | 8px | 8px | no | part item. |
itemPaddingInline | layout.inset.md | 12px | 12px | no | part item: Inline padding of the rows, and also of the header and the cancel row, which have no inline binding of their own. |
itemGap | layout.gap.normal | 8px | 8px | no | part item: Between icon and label. Rows have no gap between them: their rhythm comes from itemPaddingBlock. |
headerPaddingBlock | space.sm | 8px | 8px | no | part header: Vertical padding of the header (handle + heading) and of the cancel row; their inline padding is itemPaddingInline. When no header is rendered (not dismissible and no heading) nothing compensates for it: the first row’s own itemPaddingBlock is the top spacing. |
headerGap | layout.gap.tight | 4px | 4px | no | part header: Between the handle and the heading. |
handle | color.foreground.muted | #56585c | #bcbec1 | yes | part handle: A pill (space.1 tall, space.10 wide) centered in the header, decorative and hidden from assistive technology, as BottomSheet. |
handleHeight | space.1 | 4px | 4px | no | part handle. |
handleWidth | space.10 | 40px | 40px | no | part handle. |
handleRadius | radius.full | 999px | 999px | no | part handle. |
itemHover | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part item, state hover: Paint only: hovering a row does not move focus, which stays on the roving item. React Native has no hover on touch, so the pressed state paints it too (react-native-web also gets hover in/out). The paint change has no binding of its own: on web and Lit it transitions over motion.duration.fast with motion.easing.standard, removed under reduced motion. |
itemColor | color.foreground | #2a2b2f | #ecedee | yes | part item. |
itemDangerColor | color.foreground.danger | #b51d26 | #ff958d | yes | part item. |
titleColor | color.foreground.muted | #56585c | #bcbec1 | yes | part heading: Realized by the composed heading Text’s tone=“muted”; Text owns its color, so this is not overridable. |
titleSize | font.size.sm | 13px | 13px | no | part heading: The composed heading Text’s size=“sm”, forwarded to its fontSize override; no host hook of its own. The forward always reaches Text with ActionSheet’s resolved value: on web and Lit through Text’s CSS hook (--ds-text-font-size) set to --ds-action-sheet-title-size in the sheet’s stylesheet, with overrides.fontSize passed only when the caller set this override, so consumer CSS on the sheet hook still works; on rn the resolved value (the default token or the override) is always passed in Text’s overrides. Text sets that same hook on that same element from its own size rule, so the sheet’s declaration has to out-specify it and must target the composed Text node from the sheet’s stylesheet: on web data-part="heading" goes through Text’s rest spread and is selected with a two-class descendant selector, on Lit the outer-tree rule .heading ds-text beats Text’s own :host([size]). |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part item: Applied to each row (not the list) and forwarded to the composed heading Text as an override, since Text always sets its own family; delivered the way titleSize describes. |
fontSize | font.size.md | 16px | 16px | no | part item. |
itemIconSize | font.size.md | 16px | 16px | no | part itemIcon: Size of the row glyph, forwarded to the composed Icon as its size override so the glyph tracks the row’s type instead of keeping Icon’s own default; delivered the way titleSize describes. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part item: Applied to each row and forwarded to the composed heading Text as an override, as fontFamily is (delivered the way titleSize describes). |
divider | color.border | #d9dbdd | #414246 | no | part divider: Two dividers, two rules. The danger-group divider is drawn only when there are both default and danger actions; it sits inside the menu and is exposed as role=“separator” (rn: a View with role=“separator” among the rows). The cancel divider sits above the cancel row whenever that row is rendered, outside the menu; it is decorative and hidden from assistive technology (aria-hidden; rn: importantForAccessibility=“no-hide-descendants” and accessibilityElementsHidden). Both carry the one divider part name; where a test has to address one of them they are in DOM order, the danger divider first and the cancel divider last. |
dividerWidth | border.width.thin | 1px | 1px | no | part divider. |
minTarget | size.target.comfortable | 44px | 44px | yes | |
maxWidth | layout.maxWidth.prose | 572px | 572px | yes | Above this width, present as a Menu anchored to the opener; the comparison is (width > token), so exactly the token width is the sheet (React Native has no wide presentation; see its note). The breakpoint is read from the theme token, not per instance. When the token cannot be resolved (no theme stylesheet yet, or jsdom) the sheet presentation is used; the comparison is retried while the document is still loading, so a late stylesheet corrects the presentation. |
layer | layer.sheet | 200 | 200 | no | part surface: Stacking order. It has no effect inside the browser top layer or a native Modal window; it applies to the non-top-layer fallback (position: fixed) and the rn anchor view. On web and Lit it is written on the |
enter | motion.duration.base | 200ms | 200ms | no | part surface: Slide up from the bottom edge with motion.easing.standard, the scrim fading with the same duration and easing; instant under reduced motion. A sheet that mounts already open plays it too, as BottomSheet does. |
exit | motion.duration.fast | 120ms | 120ms | no | part surface: Slide down with motion.easing.exit, the scrim fading with the same duration and easing. A below-threshold drag release springs back with this duration and motion.easing.standard, instant under reduced motion. One duration hook serves both; the two easings are fixed tokens chosen per case, with no hooks of their own. After a dismissing release the surface holds the released offset until the consumer’s next render: open false plays this exit from there; open still true springs back (motion.easing.standard over this duration), and an interrupted enter animation is finished by that spring-back. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes |
Alert
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
background | color.status.info.background | #f1f5ff | #101d53 | yes | part container. |
background | color.status.success.background | #e0ffe0 | #002e05 | yes | part container. |
background | color.status.warning.background | #fff4e1 | #322000 | yes | part container. |
background | color.status.danger.background | #fff2f0 | #4c0005 | yes | part container. |
foreground | color.status.info.foreground | #405bb6 | #c2d4ff | yes | part heading: Heading color. |
foreground | color.status.success.foreground | #00791d | #a0eaa2 | yes | part heading: Heading color. |
foreground | color.status.warning.foreground | #825b00 | #fccc78 | yes | part heading: Heading color. |
foreground | color.status.danger.foreground | #b51d26 | #ffc3bd | yes | part heading: Heading color. |
bodyColor | color.foreground | #2a2b2f | #ecedee | yes | part body: Body text keeps the page foreground so long messages read as text, not as colored emphasis. On React Native a string or number body is wrapped in the system Text, whose default tone is this color, so no color is passed; a non-string body is rendered as given. A body of Text or Link children therefore keeps its own color on every platform: this binding reaches plain text only, by inheritance on web and Lit, and inheriting a value is not restyling a child. |
border | color.status.info.border | #99b5ff | #2d4597 | no | part container. |
border | color.status.success.border | #76d07a | #005e14 | no | part container. |
border | color.status.warning.border | #e6ac3f | #664700 | no | part container. |
border | color.status.danger.border | #ff958d | #940014 | no | part container. |
icon | color.status.info.icon | #405bb6 | #99b5ff | yes | part icon: Leading icon: info circle, check circle, warning triangle, or error octagon by tone, rendered with the system Icon whose glyph name is the tone value itself (info, success, warning, danger), so a new tone needs a glyph of the same name, and colored by passing this token path as overrides.color to the Icon on every platform, React Native included (never Icon’s RN color prop) — the sanctioned way to color a composed child. Locked by its non-text contrast pair, so there is no overrides.icon and no —ds-alert-icon hook: the Icon always gets this tone token, and the icon part box carries no color of its own. Decorative: the Icon has no label, so it hides itself from assistive technology (aria-hidden; on React Native accessibilityElementsHidden and importantForAccessibility no), and the Alert-owned box adds no accessibility props on any platform precisely because the Icon already hides itself — the box must not repeat them; the tone is also conveyed by the heading or role. |
icon | color.status.success.icon | #00791d | #76d07a | yes | part icon: Leading icon: info circle, check circle, warning triangle, or error octagon by tone, rendered with the system Icon whose glyph name is the tone value itself (info, success, warning, danger), so a new tone needs a glyph of the same name, and colored by passing this token path as overrides.color to the Icon on every platform, React Native included (never Icon’s RN color prop) — the sanctioned way to color a composed child. Locked by its non-text contrast pair, so there is no overrides.icon and no —ds-alert-icon hook: the Icon always gets this tone token, and the icon part box carries no color of its own. Decorative: the Icon has no label, so it hides itself from assistive technology (aria-hidden; on React Native accessibilityElementsHidden and importantForAccessibility no), and the Alert-owned box adds no accessibility props on any platform precisely because the Icon already hides itself — the box must not repeat them; the tone is also conveyed by the heading or role. |
icon | color.status.warning.icon | #825b00 | #e6ac3f | yes | part icon: Leading icon: info circle, check circle, warning triangle, or error octagon by tone, rendered with the system Icon whose glyph name is the tone value itself (info, success, warning, danger), so a new tone needs a glyph of the same name, and colored by passing this token path as overrides.color to the Icon on every platform, React Native included (never Icon’s RN color prop) — the sanctioned way to color a composed child. Locked by its non-text contrast pair, so there is no overrides.icon and no —ds-alert-icon hook: the Icon always gets this tone token, and the icon part box carries no color of its own. Decorative: the Icon has no label, so it hides itself from assistive technology (aria-hidden; on React Native accessibilityElementsHidden and importantForAccessibility no), and the Alert-owned box adds no accessibility props on any platform precisely because the Icon already hides itself — the box must not repeat them; the tone is also conveyed by the heading or role. |
icon | color.status.danger.icon | #b51d26 | #ff958d | yes | part icon: Leading icon: info circle, check circle, warning triangle, or error octagon by tone, rendered with the system Icon whose glyph name is the tone value itself (info, success, warning, danger), so a new tone needs a glyph of the same name, and colored by passing this token path as overrides.color to the Icon on every platform, React Native included (never Icon’s RN color prop) — the sanctioned way to color a composed child. Locked by its non-text contrast pair, so there is no overrides.icon and no —ds-alert-icon hook: the Icon always gets this tone token, and the icon part box carries no color of its own. Decorative: the Icon has no label, so it hides itself from assistive technology (aria-hidden; on React Native accessibilityElementsHidden and importantForAccessibility no), and the Alert-owned box adds no accessibility props on any platform precisely because the Icon already hides itself — the box must not repeat them; the tone is also conveyed by the heading or role. |
borderWidth | border.width.thin | 1px | 1px | no | part container. |
radius | radius.md | 4px | 4px | no | part container. |
padding | space.md | 12px | 12px | no | part container. |
gap | space.3 | 12px | 12px | no | part container: Horizontal gap between icon, content, and dismiss button. |
partGap | space.1 | 4px | 4px | no | part container: Vertical gap between heading and body. |
iconSize | font.size.lg | 19px | 19px | no | part icon: Forwarded to the Icon as overrides.size (a token path, default font.size.lg); Icon’s size enum is not used here. Override through overrides.iconSize; the —ds-alert-icon-size hook does not resize the Icon. The icon part is a box as tall as the first line of text — headingSize × lineHeight when there is a heading, else fontSize × lineHeight, or iconSize when that is larger — with the Icon centred in it, so the glyph lines up with the first line; that math reads the same token as the forward. overrides.iconSize is the only supported input: the component writes —ds-alert-icon-size from it and the box math reads that hook, so a consumer who sets the hook directly in their own CSS resizes the box but not the glyph, which is unsupported. Web and Lit know whether there is a heading from a data-has-heading attribute the component sets on its root — the <div> on web and the host element on Lit, where the hooks also live, not the shadow container part — and not from a CSS :has() query; all hooks (fontSize, headingSize, lineHeight, iconSize) are set on that root and inherited, so the icon box reads them there. React Native runs the same max(): the first line’s lineHeight, or iconSize when the glyph is larger. |
headingSize | font.size.md | 16px | 16px | no | part heading: The heading; body text uses fontSize. |
headingWeight | font.weight.semibold | 600 | 600 | no | part heading. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part container: Inherited by heading and a string body on web and Lit; on React Native forwarded to the heading Text style and to the body Text’s overrides.fontFamily. |
fontSize | font.size.md | 16px | 16px | no | part body: Body text. It reaches a string body directly (on React Native through the wrapping Text’s overrides.fontSize, with lineHeight forwarded the same way); a body composed of Text or Link children keeps its own sizing, since a composite never restyles a child. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part container: Set on the root and inherited by the heading, the body and the icon-box math; the heading has no line-height binding of its own and uses this one. |
dismissMargin | space.1 | 4px | 4px | no | part dismissButton: Negative margin-block-start and margin-inline-end only (not block-end) on the wrapper around the dismiss Button (web span, Lit span, RN View) so its target sits in the corner without enlarging the padding; the Button keeps its own colors, radius and focus ring. On React Native this is the one sanctioned sibling margin (marginTop and marginEnd of −dismissMargin on the wrapper View). Excluding block-end is a React Native rule, where the wrapper View can otherwise stretch; on web and Lit the container aligns its children to the start, so the wrapper never reaches the block-end padding and the exclusion has no visible effect. |
AlertDialog
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
scrim | color.overlay.scrim | #00000066 | #00000099 | no | part scrim. |
surface | color.overlay.surface | #ffffff | #2a2b2f | yes | part surface. |
border | color.border | #d9dbdd | #414246 | no | |
borderWidth | border.width.thin | 1px | 1px | no | |
shadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | |
radius | radius.lg | 6px | 6px | no | |
inset | layout.inset.lg | 16px | 16px | no | Pads the inline edges of every part; the block edges are padded once, on the surface column that holds the parts (padding-block: inset), not per part, so nothing doubles. partGap is the only space between the icon-and-text row and the footer. |
partGap | layout.gap.loose | 16px | 16px | no | part surface: The gap of the surface’s column, between the icon-and-text row and the footer: the icon sits inline with the text block, so this measures from that whole row. |
textGap | layout.gap.tight | 4px | 4px | no | Between title and description: the gap of the unnamed column that holds them inside the icon-and-text row, which is why it names no part. inset’s inline padding covers that row, so the icon never touches the surface edge. |
iconGap | layout.gap.normal | 8px | 8px | no | part surface: Between the icon and the text block — the gap of the row inside the surface that holds both, not a property of the icon element (an icon cannot own a gap). |
footerGap | layout.gap.tight | 4px | 4px | no | part footer: Forwarded to the footer Stack’s gap. The forward always reaches the Stack with AlertDialog’s resolved value: on web and Lit the AlertDialog stylesheet sets the Stack’s own --ds-stack-gap hook on the Stack element to --ds-alert-dialog-footer-gap, and overrides.gap is passed only when the caller set the footerGap override, so consumer CSS on the AlertDialog hook still works; on rn the resolved value (the token or the override) is always passed as overrides.gap. It has no hook beyond --ds-alert-dialog-footer-gap. That hook is the delivery path; the Stack’s declared gap="tight" prop resolves to the same token and is the composition’s stated default, not a second route. |
iconSize | font.size.lg | 19px | 19px | no | part icon: Forwarded to the tone Icon’s size; the Icon gets no size prop. Delivered as footerGap is: on web and Lit the stylesheet sets the Icon’s --ds-icon-size hook to --ds-alert-dialog-icon-size and overrides.size is passed only when the caller set the override; on rn the resolved value is always passed as overrides.size. No hook beyond --ds-alert-dialog-icon-size. |
icon | color.status.danger.icon | #b51d26 | #ff958d | yes | part icon: Forwarded to the tone Icon’s color. It is the foreground of the non-text contrast pair and so is not a member of the overrides type; the caller never overrides it and it is always delivered: on web and Lit a per-tone rule in the AlertDialog stylesheet sets the Icon’s own --ds-icon-color hook on the Icon element (setting color on an ancestor or wrapper has no effect, because Icon’s own rule wins); on rn overrides.color is always passed. These mechanics are AlertDialog’s own: Alert and Toast reach the same result by passing overrides.color from render on every platform, and the difference is deliberate, not a drift to be copied either way. |
icon | color.status.warning.icon | #825b00 | #e6ac3f | yes | part icon: Forwarded to the tone Icon’s color. It is the foreground of the non-text contrast pair and so is not a member of the overrides type; the caller never overrides it and it is always delivered: on web and Lit a per-tone rule in the AlertDialog stylesheet sets the Icon’s own --ds-icon-color hook on the Icon element (setting color on an ancestor or wrapper has no effect, because Icon’s own rule wins); on rn overrides.color is always passed. These mechanics are AlertDialog’s own: Alert and Toast reach the same result by passing overrides.color from render on every platform, and the difference is deliberate, not a drift to be copied either way. |
icon | color.status.info.icon | #405bb6 | #99b5ff | yes | part icon: Forwarded to the tone Icon’s color. It is the foreground of the non-text contrast pair and so is not a member of the overrides type; the caller never overrides it and it is always delivered: on web and Lit a per-tone rule in the AlertDialog stylesheet sets the Icon’s own --ds-icon-color hook on the Icon element (setting color on an ancestor or wrapper has no effect, because Icon’s own rule wins); on rn overrides.color is always passed. These mechanics are AlertDialog’s own: Alert and Toast reach the same result by passing overrides.color from render on every platform, and the difference is deliberate, not a drift to be copied either way. |
width | layout.maxWidth.prose | 572px | 572px | no | part surface: Always the small size; an alert dialog with more content is a Dialog. The width sits on the surface itself: inline-size min(width, viewport width − 2 × gutter). On web and Lit the |
gutter | layout.gutter | 24px | 24px | no | part surface: The least space between the surface and each viewport edge: caps the surface width at viewport width − 2 × gutter and its max height at viewport height − 2 × gutter, both set on the surface. Content that exceeds that height cap scrolls the surface itself — the block padding and the footer scroll with it, and nothing is pinned; an alert dialog that needs a pinned footer has too much content and is a Dialog. On rn spacing is never a margin: the gutter is paddingHorizontal on the centering View, and the surface maxHeight is window height − 2 × gutter from useWindowDimensions; the sized, shadowed, animated box there is the Animated wrapper around the surface, with the surface flexShrinking inside it. |
layer | layer.dialog | 300 | 300 | no | Kept as the hook for consistency with Dialog, but it has no effect anywhere this component renders: web and Lit always open with showModal() into the top layer and have no non-top-layer fallback (the hook is written as z-index on the |
rise | space.2 | 8px | 8px | no | part surface: The distance the surface rises during enter (translateY from rise to 0); zero under reduced motion. |
enter | motion.duration.base | 200ms | 200ms | no | One duration shared by the scrim fade and the surface fade-and-rise (translateY of rise), with motion.easing.standard; instant under reduced motion. |
exit | motion.duration.fast | 120ms | 120ms | no | One duration shared by the scrim and surface fade-out, with motion.easing.exit; instant under reduced motion. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes |
BottomSheet
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
scrim | color.overlay.scrim | #00000066 | #00000099 | no | part scrim. |
surface | color.overlay.surface | #ffffff | #2a2b2f | yes | part surface. |
shadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | part surface. |
radius | radius.lg | 6px | 6px | no | Top corners only, at every width: the surface always meets the bottom edge. |
handle | color.foreground.muted | #56585c | #bcbec1 | yes | part handle: A 4×36-unit pill (space.1 tall, space.10 wide) centered in the header, decorative. |
handleHeight | space.1 | 4px | 4px | no | part handle. |
handleWidth | space.10 | 40px | 40px | no | part handle. |
handleRadius | radius.full | 999px | 999px | no | part handle: Rounds the handle into a pill. |
headerPaddingTop | space.sm | 8px | 8px | no | part surface: Block-start padding above the handle. It is the block-start padding of the column that holds the parts (see inset) when the handle is rendered, in place of inset; it is applied once there, never as extra header padding. |
handleGap | layout.gap.tight | 4px | 4px | no | part header: The header is a column of the handle over the heading row; this is that column’s gap, between the handle and the heading row. |
headerGap | layout.gap.normal | 8px | 8px | no | part header: The flex gap of the heading row, between the heading and the close button, as Dialog’s headerGap. The heading row is a sheet-owned element inside the header, not an anatomy part, with no data-part or testID. With the heading hidden (hideHeading) the close button is end-aligned in the row; a header with no visible heading, no handle and no close button is not rendered, on every platform — on web and Lit the visually hidden heading then sits at the start of the column, and on rn nothing is rendered because the name is the surface’s accessibilityLabel. |
inset | layout.inset.lg | 16px | 16px | no | Inline padding of every part: the header and footer wrappers apply it themselves and the body Box receives it as overrides.paddingInline (its block padding stays zero). The block edges are padded once, on the column that holds header, body and footer (the surface): padding-block-start inset (headerPaddingTop when the handle is rendered) and padding-block-end inset, plus the bottom safe-area inset at every width. No part has block padding of its own, so nothing doubles. |
partGap | layout.gap.loose | 16px | 16px | no | The only space between header, body and footer: the flex gap of the column. Box never scrolls, so the body part is a Box inside a sheet-owned scroll element (unnamed, no part hook of its own); it is that wrapper the gap measures to. On rn the empty SafeAreaView that adds the bottom inset is not a part either and takes no gap: cancel the column gap above it, so the bottom clearance is exactly inset plus the safe-area inset. |
footerGap | layout.gap.tight | 4px | 4px | no | part footer: Between footer actions; forwarded to the footer Stack as overrides.gap, the footer row end-aligned as in Dialog. A forward always reaches the Stack with the resolved value: on web and Lit through the Stack’s own --ds-stack-gap hook, set in the sheet’s stylesheet to --ds-bottom-sheet-footer-gap, with overrides.gap passed only when the caller set that override, so consumer CSS on the sheet’s hook still reaches the Stack — this is delivery of the forward, not restyling the child; on rn the resolved value (token or override) is always passed in overrides; inset reaches the body the same way. |
maxWidth | layout.maxWidth.prose | 572px | 572px | yes | part surface: The surface’s maximum inline size, centred with equal inline margins: the surface is min(viewport width, token) wide and stays anchored to the bottom edge, so on a phone it is full width and on a wide viewport it is a capped, centred sheet. It is not a breakpoint — there is one presentation at every width, never a Dialog, and nothing is decided in script. Web and Lit: max-inline-size on the surface with margin-inline: auto; rn: the surface’s maxWidth with alignSelf: center, so a tablet wider than the token gets the same capped sheet at the bottom. |
layer | layer.sheet | 200 | 200 | no | Has no effect inside the browser top layer or a native Modal window; applies to the non-top-layer fallback (position: fixed) and the rn anchor view. |
enter | motion.duration.base | 200ms | 200ms | no | Slide up from the bottom edge with the scrim fading in the same duration and easing; motion.easing.standard; instant under reduced motion. |
exit | motion.duration.fast | 120ms | 120ms | no | Slide down with motion.easing.exit; the scrim fades out in the same duration and easing. A drag dismiss plays this same transition from wherever the finger left the sheet — there is no momentum or decay physics, so no platform needs a velocity-to-animation mapping. A below-threshold release springs back to rest with this duration and motion.easing.standard (a timing animation, not a spring), instant under reduced motion. |
minTarget | size.target.comfortable | 44px | 44px | yes | part closeButton: Sheets are used one-handed, so the close button is raised to the comfortable target without changing Button: the closeButton part is a sheet-owned wrapper (web/Lit element with data-part="closeButton"; rn View with testID="BottomSheet.closeButton") with this minimum inline and block size, whose extra area activates the Button (web/Lit: a click on the wrapper outside the Button focuses the Button and requests close with reason close-button from the wrapper’s own click handler, never reaching into the Button’s internals or shadow root; rn: the wrapper View takes the min size and the Button’s own internally computed hitSlop — rn Button accepts no hitSlop prop, it derives one from size.target.comfortable — covers the extra area). The Button keeps its own size variant (ghost, sm, icon-only, as in Dialog) and colors. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes |
Box
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
paddingBlock | layout.inset.none | 0px | 0px | no | An override applies at every value including none: layout.inset.none is a real token (a zero), not an absent part, so padding is not one of the bindings presence gates. |
paddingBlock | layout.inset.sm | 8px | 8px | no | An override applies at every value including none: layout.inset.none is a real token (a zero), not an absent part, so padding is not one of the bindings presence gates. |
paddingBlock | layout.inset.md | 12px | 12px | no | An override applies at every value including none: layout.inset.none is a real token (a zero), not an absent part, so padding is not one of the bindings presence gates. |
paddingBlock | layout.inset.lg | 16px | 16px | no | An override applies at every value including none: layout.inset.none is a real token (a zero), not an absent part, so padding is not one of the bindings presence gates. |
paddingBlock | layout.inset.xl | 32px | 32px | no | An override applies at every value including none: layout.inset.none is a real token (a zero), not an absent part, so padding is not one of the bindings presence gates. |
paddingInline | layout.inset.none | 0px | 0px | no | |
paddingInline | layout.inset.sm | 8px | 8px | no | |
paddingInline | layout.inset.md | 12px | 12px | no | |
paddingInline | layout.inset.lg | 16px | 16px | no | |
paddingInline | layout.inset.xl | 32px | 32px | no | |
background | color.background.none | — | — | yes | none renders the literal transparent, not a token, written out explicitly (background-color: transparent) and not read through the hook, so neither overrides.background nor consumer CSS on --ds-box-background paints a none box; the token binding covers the other three values, and the --ds-box-background hook is set only by those three, with no base default. Interpolated bindings like this one are locked — they keep their --ds-box-* hook, which is the consumer’s own-CSS escape hatch, but they are not members of the overrides type. |
background | color.background | #ffffff | #17181b | yes | none renders the literal transparent, not a token, written out explicitly (background-color: transparent) and not read through the hook, so neither overrides.background nor consumer CSS on --ds-box-background paints a none box; the token binding covers the other three values, and the --ds-box-background hook is set only by those three, with no base default. Interpolated bindings like this one are locked — they keep their --ds-box-* hook, which is the consumer’s own-CSS escape hatch, but they are not members of the overrides type. |
background | color.background.subtle | #f6f7f8 | #2a2b2f | yes | none renders the literal transparent, not a token, written out explicitly (background-color: transparent) and not read through the hook, so neither overrides.background nor consumer CSS on --ds-box-background paints a none box; the token binding covers the other three values, and the --ds-box-background hook is set only by those three, with no base default. Interpolated bindings like this one are locked — they keep their --ds-box-* hook, which is the consumer’s own-CSS escape hatch, but they are not members of the overrides type. |
background | color.background.strong | #ecedee | #414246 | yes | none renders the literal transparent, not a token, written out explicitly (background-color: transparent) and not read through the hook, so neither overrides.background nor consumer CSS on --ds-box-background paints a none box; the token binding covers the other three values, and the --ds-box-background hook is set only by those three, with no base default. Interpolated bindings like this one are locked — they keep their --ds-box-* hook, which is the consumer’s own-CSS escape hatch, but they are not members of the overrides type. |
border | color.border | #d9dbdd | #414246 | no | The border colour. It shares a name with the border boolean, which decides presence: an override recolours the border and never brings one into existence. On web and Lit, without border the width is 0 and the colour hook may stay written, since nothing visible reads it; on React Native, which has no cascade, borderWidth and borderColor are left unset when border is false. |
borderWidth | border.width.thin | 1px | 1px | no | |
radius | radius.none | 0px | 0px | no | radius: none resolves radius.none and is written out, rather than leaving the property unset — every binding is applied explicitly, with no cascade. Unlike padding, radius is presence-gated: none means no rounded corners, so overrides.radius is ignored at none and applies at every other value. As with surface: none, the none rule writes radius.none directly and reads no hook, so consumer CSS on --ds-box-radius cannot round a none box either. |
radius | radius.sm | 2px | 2px | no | radius: none resolves radius.none and is written out, rather than leaving the property unset — every binding is applied explicitly, with no cascade. Unlike padding, radius is presence-gated: none means no rounded corners, so overrides.radius is ignored at none and applies at every other value. As with surface: none, the none rule writes radius.none directly and reads no hook, so consumer CSS on --ds-box-radius cannot round a none box either. |
radius | radius.md | 4px | 4px | no | radius: none resolves radius.none and is written out, rather than leaving the property unset — every binding is applied explicitly, with no cascade. Unlike padding, radius is presence-gated: none means no rounded corners, so overrides.radius is ignored at none and applies at every other value. As with surface: none, the none rule writes radius.none directly and reads no hook, so consumer CSS on --ds-box-radius cannot round a none box either. |
radius | radius.lg | 6px | 6px | no | radius: none resolves radius.none and is written out, rather than leaving the property unset — every binding is applied explicitly, with no cascade. Unlike padding, radius is presence-gated: none means no rounded corners, so overrides.radius is ignored at none and applies at every other value. As with surface: none, the none rule writes radius.none directly and reads no hook, so consumer CSS on --ds-box-radius cannot round a none box either. |
radius | radius.full | 999px | 999px | no | radius: none resolves radius.none and is written out, rather than leaving the property unset — every binding is applied explicitly, with no cascade. Unlike padding, radius is presence-gated: none means no rounded corners, so overrides.radius is ignored at none and applies at every other value. As with surface: none, the none rule writes radius.none directly and reads no hook, so consumer CSS on --ds-box-radius cannot round a none box either. |
Breadcrumb
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
currentColor | color.foreground | #2a2b2f | #ecedee | yes | part current: The current page, rendered as text with aria-current, in the regular weight. |
itemColor | color.foreground.muted | #56585c | #bcbec1 | yes | part item: Set on the item (the <li> on web and Lit), so it reaches an ancestor without href rendered as plain text (a level that has no page of its own); the Link and the current page set their own colours over it. On native, where colour does not inherit through a View, the plain ancestor Text takes it directly. |
separatorColor | color.foreground.muted | #56585c | #bcbec1 | yes | part separator: A slash or chevron between items, aria-hidden. On web and Lit the separator is the ::before of every item after the first, so it has no element, data-part, minTarget or focus hook of its own and this binding styles that pseudo-element; on native it is a Text with testID Breadcrumb.separator. |
gap | space.2 | 8px | 8px | no | part list: Applied twice, never as margins: as the list’s column gap between items, and as the gap inside each item between its leading separator and its content. The separator belongs to the item after it, so a wrapped line may start with a separator. The ellipsis sits in its own item like any other, so it carries a leading separator too and a collapsed trail reads Docs / … / Breadcrumb / Keyboard. Row gap between wrapped lines is none; the items’ minTarget height spaces the lines. An override of gap applies in both places. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part item: Only the focus-fallback item (the <li> given tabindex="-1" after expanding, web and Lit) draws it, as an outline under :focus-visible; Link and Button draw their own rings. Not applied on native, where that focus is screen-reader focus only. Locked bindings still get their —ds-breadcrumb-* hook on both web and Lit — locked means absent from the overrides type, not absent from CSS — so this ring stays reachable from consumer CSS. Native :focus-visible semantics are kept as they are: after a mouse press on the ellipsis the browser does not match the programmatically focused <li>, so no ring is drawn, and no :focus fallback is added to force one. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part item: Outline width of the focus-fallback item ring; web and Lit only. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part nav: Set on the nav and inherited on web and Lit; on native forwarded like fontSize. |
fontSize | font.size.sm | 13px | 13px | no | part nav: Set on the nav; the Links inherit it on web and Lit. On native each ancestor Link is wrapped in a Text (inside the link part’s View) whose overrides receive fontSize, fontFamily, fontWeight and lineHeight (and any override of them), and plain ancestors, the current page and separators are Text at the same values, so the type is one value everywhere. |
fontWeight | font.weight.regular | 400 | 400 | no | part nav: Set on the nav and inherited on web and Lit; on native forwarded like fontSize. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part nav: Set on the nav and inherited on web and Lit; on native forwarded like fontSize. |
minTarget | size.target.min | 24px | 24px | yes | part item: Each item reaches 24px tall via min-height on the list item, not on the inline Link. |
Button
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
background | color.action.primary.background | #3553d2 | #3553d2 | yes | |
background | color.action.secondary.background | #ecedee | #414246 | yes | |
background | color.action.ghost.background | transparent | transparent | yes | |
background | color.action.danger.background | #b51d26 | #d33a3c | yes | |
backgroundHover | color.action.primary.backgroundHover | #243ab3 | #496ded | yes | state hover: Pointer hover and pressed state (:hover and :active on web and Lit; pressed on rn, which does not read the Pressable hovered state, so a react-native-web mouse hover leaves the fill alone). The declared state: hover names the token slot; the rule applies the value to :is(:hover, :active), as the two read as one pressed-or-pointed state here. Never applied while disabled or loading, since neither accepts a press; the same holds for inverseBackgroundHover. Web and Lit suppress it by selector — :not([aria-disabled='true']):not([aria-busy='true']) — so an aria-busy a caller passes through ...rest also suppresses the fill, which is right: a busy button is not accepting presses either. Locked like background and foreground, so an override cannot put an unproven fill behind the locked foreground. |
backgroundHover | color.action.secondary.backgroundHover | #d9dbdd | #56585c | yes | state hover: Pointer hover and pressed state (:hover and :active on web and Lit; pressed on rn, which does not read the Pressable hovered state, so a react-native-web mouse hover leaves the fill alone). The declared state: hover names the token slot; the rule applies the value to :is(:hover, :active), as the two read as one pressed-or-pointed state here. Never applied while disabled or loading, since neither accepts a press; the same holds for inverseBackgroundHover. Web and Lit suppress it by selector — :not([aria-disabled='true']):not([aria-busy='true']) — so an aria-busy a caller passes through ...rest also suppresses the fill, which is right: a busy button is not accepting presses either. Locked like background and foreground, so an override cannot put an unproven fill behind the locked foreground. |
backgroundHover | color.action.ghost.backgroundHover | #f6f7f8 | #2a2b2f | yes | state hover: Pointer hover and pressed state (:hover and :active on web and Lit; pressed on rn, which does not read the Pressable hovered state, so a react-native-web mouse hover leaves the fill alone). The declared state: hover names the token slot; the rule applies the value to :is(:hover, :active), as the two read as one pressed-or-pointed state here. Never applied while disabled or loading, since neither accepts a press; the same holds for inverseBackgroundHover. Web and Lit suppress it by selector — :not([aria-disabled='true']):not([aria-busy='true']) — so an aria-busy a caller passes through ...rest also suppresses the fill, which is right: a busy button is not accepting presses either. Locked like background and foreground, so an override cannot put an unproven fill behind the locked foreground. |
backgroundHover | color.action.danger.backgroundHover | #940014 | #b51d26 | yes | state hover: Pointer hover and pressed state (:hover and :active on web and Lit; pressed on rn, which does not read the Pressable hovered state, so a react-native-web mouse hover leaves the fill alone). The declared state: hover names the token slot; the rule applies the value to :is(:hover, :active), as the two read as one pressed-or-pointed state here. Never applied while disabled or loading, since neither accepts a press; the same holds for inverseBackgroundHover. Web and Lit suppress it by selector — :not([aria-disabled='true']):not([aria-busy='true']) — so an aria-busy a caller passes through ...rest also suppresses the fill, which is right: a busy button is not accepting presses either. Locked like background and foreground, so an override cannot put an unproven fill behind the locked foreground. |
foreground | color.action.primary.foreground | #ffffff | #ffffff | yes | |
foreground | color.action.secondary.foreground | #000000 | #ffffff | yes | |
foreground | color.action.ghost.foreground | #3553d2 | #99b5ff | yes | |
foreground | color.action.danger.foreground | #ffffff | #ffffff | yes | |
iconGap | space.2 | 8px | 8px | no | Gap between an icon and the label. Deliberately a step of the numeric scale rather than the t-shirt ramp the paddings use: the gap between a glyph and its word is one fixed distance and does not grow with size. |
paddingInline | space.sm | 8px | 8px | no | |
paddingInline | space.md | 12px | 12px | no | |
paddingInline | space.lg | 16px | 16px | no | |
paddingBlock | space.sm | 8px | 8px | no | |
radius | radius.md | 4px | 4px | no | |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | |
fontWeight | font.weight.medium | 500 | 500 | no | |
fontSize | font.size.sm | 13px | 13px | no | |
fontSize | font.size.md | 16px | 16px | no | |
fontSize | font.size.lg | 19px | 19px | no | |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes | |
focusRingOffset | border.width.focus | 2px | 2px | yes | Outline offset, so the ring clears the button’s own fill instead of sitting on its edge. The same focus token as focusRingWidth and locked with it, so the rule reads the token directly and there is no literal. |
inverseForeground | color.inverse.link | #99b5ff | #243ab3 | yes | ghost text when inverse. |
inverseFocusRing | color.inverse.focus | #99b5ff | #243ab3 | yes | Focus ring when inverse. |
inverseBackgroundHover | color.inverse.foreground | #f6f7f8 | #17181b | no | state hover: ghost hover and pressed fill when inverse: this color at inverseHoverOpacity. The color-mix on web and Lit takes transparent as its second colour, never color.inverse.surface — ghost has no fill of its own, so whatever the Toast or panel actually paints has to show through, and mixing against the token would be right only when the two happen to match. On native it is the same thing by another route: the resolved colour with an alpha channel, drawn over whatever the transparent ghost sits on; a colour that is not #rgb or #rrggbb passes through without alpha. |
inverseHoverOpacity | opacity.disabled | 0.5 | 0.5 | no | state hover: The alpha of inverseBackgroundHover: color-mix percentage on web/Lit (calc(var(--ds-button-inverse-hover-opacity) * 0.25 * 100%)), the alpha of the resolved color on native. The ×0.25 applies to whatever token the binding resolves to, an override included: the hook holds the base token and the rule that reads it multiplies. |
minTarget | size.target.min | 24px | 24px | yes | min-width and min-height of the button on every platform. Web and Lit apply only this floor; there is no coarse-pointer rule. |
touchTarget | size.target.comfortable | 44px | 44px | yes | The hit area on touch platforms, reached without changing the visual size: hitSlop on rn, .contentShape on SwiftUI. On rn the slop per side is half the shortfall — ceil((touchTarget − measured extent) / 2) — and the footprint is presumed to be minTarget until the first onLayout, so the comfortable target is never under-served on the first frame. react-native-web ignores hitSlop, so in the web preview the target is the visual box alone: it clears the 24px floor through minTarget, and the 44px target is not observable there. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | Applied to the whole button when disabled; colors are unchanged so contrast math still holds for the enabled state. |
transition | motion.duration.fast | 120ms | 120ms | no | Background transitions on hover and press, with motion.easing.standard. No variant changes its foreground between states, so only the background animates: the property list is background-color alone, and a variant swapped at runtime therefore changes fill without a transition. Removed under @media (prefers-reduced-motion: reduce) { transition: none } — the reduce query, never an inverted no-preference gate, which would also strip motion where the feature is unsupported. |
loadingSpin | motion.duration.loop | 800ms | 800ms | no | One rotation of the loading indicator, at linear easing (a continuous spin; motion.easing.standard is for transition only); disabled under prefers-reduced-motion, written as the same reduce query as transition. The ring stays rendered and frozen at its start angle rather than being hidden — it is the only visual sign that the button is busy — and transition likewise snaps the background instead of animating. |
spinnerSize | font.size.sm | 13px | 13px | no | Diameter of the loading ring: 1em of the label font. On rn, which has no em, the ring is this size and its radius is half of it. Its own binding: a fontSize override does not move it, and a consumer who changes one overrides both. |
spinnerSize | font.size.md | 16px | 16px | no | Diameter of the loading ring: 1em of the label font. On rn, which has no em, the ring is this size and its radius is half of it. Its own binding: a fontSize override does not move it, and a consumer who changes one overrides both. |
spinnerSize | font.size.lg | 19px | 19px | no | Diameter of the loading ring: 1em of the label font. On rn, which has no em, the ring is this size and its radius is half of it. Its own binding: a fontSize override does not move it, and a consumer who changes one overrides both. |
spinnerStroke | border.width.focus | 2px | 2px | yes | Ring thickness of the loading spinner: a spinnerSize circle with one quarter transparent — the block-start quarter on every platform, so the turn reads as starting from twelve o’clock — drawn in currentColor (the resolved foreground binding on rn). Locked because border.width.focus is a focus token: it keeps its --ds-button-spinner-stroke hook but is not a member of the overrides type. That the hook stays settable from document CSS is intended — locked removes a binding from overrides, never from the CSS escape hatch. The ring is round through radius.full, read directly with no hook, on web and Lit, and through a radius of spinnerSize / 2 on rn, where it is a bordered View with a transparent top border (no react-native-svg) and carries no testID. One rotation is a full turn, rotate(360deg): a geometric constant, not a themed value. |
Card
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
paddingBlock | layout.inset.sm | 8px | 8px | no | |
paddingBlock | layout.inset.md | 12px | 12px | no | |
paddingBlock | layout.inset.lg | 16px | 16px | no | |
paddingInline | layout.inset.sm | 8px | 8px | no | |
paddingInline | layout.inset.md | 12px | 12px | no | |
paddingInline | layout.inset.lg | 16px | 16px | no | |
partGap | layout.gap.loose | 16px | 16px | no | Vertical gap between header, body and footer. |
headerGap | layout.gap.normal | 8px | 8px | no | part header: Horizontal gap between the heading and headerActions. |
footerGap | layout.gap.tight | 4px | 4px | no | part footer: Horizontal gap between footer actions. |
actionsGap | layout.gap.tight | 4px | 4px | no | part headerActions: Horizontal gap between the headerActions controls. |
background | color.background | #ffffff | #17181b | yes | |
background | color.background.subtle | #f6f7f8 | #2a2b2f | yes | |
border | color.border | #d9dbdd | #414246 | no | In effect with surface default only; a subtle card draws no border, so an override of this binding changes nothing there. |
borderWidth | border.width.thin | 1px | 1px | no | Rendered only with surface default. An interactive card always reserves border.width.focus instead, so the ring appearing never shifts the layout; at rest that border is colored border on surface default (the card keeps its visible border) and transparent on subtle, and focusRing while the ring shows. The reserve is keyed on the interactive prop alone, not on whether a target was found, so a card that fell back to non-interactive still reserves the width rather than changing its geometry on a content change. React Native reserves it for focusable cards too, since it has no outline to draw outside the box. An override of this binding therefore only changes cards that are neither interactive nor (on native) focusable. |
radius | radius.lg | 6px | 6px | no | |
hoverBackground | color.background.subtle | #f6f7f8 | #2a2b2f | yes | state hover: Interactive cards only, on pointer hover (a plain :hover on web and Lit, no @media (hover: hover) guard, as Link). The surface picks the token — a subtle card cannot hover to its own background — and the same lookup applies on every platform. Native has no hover, so the Pressable shows it while pressed, and on pointer hover where the platform reports one (iPad pointer, react-native-web). |
hoverBackground (surface=default) | color.background.subtle | #f6f7f8 | #2a2b2f | yes | state hover: Interactive cards only, on pointer hover (a plain :hover on web and Lit, no @media (hover: hover) guard, as Link). The surface picks the token — a subtle card cannot hover to its own background — and the same lookup applies on every platform. Native has no hover, so the Pressable shows it while pressed, and on pointer hover where the platform reports one (iPad pointer, react-native-web). |
hoverBackground (surface=subtle) | color.background.strong | #ecedee | #414246 | yes | state hover: Interactive cards only, on pointer hover (a plain :hover on web and Lit, no @media (hover: hover) guard, as Link). The surface picks the token — a subtle card cannot hover to its own background — and the same lookup applies on every platform. Native has no hover, so the Pressable shows it while pressed, and on pointer hover where the platform reports one (iPad pointer, react-native-web). |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes | |
transition | motion.duration.fast | 120ms | 120ms | no | Interactive hover, with motion.easing.standard. Native has no continuous hover to animate between — the pressed style swaps instantly — so the binding exists there for API parity and has no runtime effect. |
Carousel
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
slideGap | layout.gap.normal | 8px | 8px | no | part slide: Applied as the track’s gap, never a slide margin (on Lit the slides are slotted, so only the track can carry it). |
controlOffset | space.2 | 8px | 8px | no | Distance of the arrow buttons from the viewport edge when overlaid: the inline inset of each controlSurface from the viewport’s inline edge. |
controlRadius | radius.full | 999px | 999px | no | The controlSurface wrapper’s corner radius. |
controlBackground | color.overlay.surface | #ffffff | #2a2b2f | yes | The controlSurface wrapper around each arrow Button (the Button itself is secondary and untouched) so they stay readable over images. |
controlShadow | shadow.raised | 0px 1px 3px 0px #0000001a | 0px 1px 3px 0px #00000059 | no | On the controlSurface wrapper. |
pickerGap | layout.gap.tight | 4px | 4px | no | part picker. |
pickerOffset | space.3 | 12px | 12px | no | part picker: Between the viewport and the picker row below it, and between the play/pause row above it and the viewport; a gap between rows, not a margin. |
dot | color.border.strong | #939599 | #707176 | yes | |
dotActive | color.control.selectedBackground | #3553d2 | #496ded | yes | |
dotSize | space.2 | 8px | 8px | no | |
dotTarget | size.target.min | 24px | 24px | yes | Each dot’s hit area; the dot itself is small. |
dotRadius | radius.full | 999px | 999px | no | part pickerItem: Dots are round; a tab has no radius. The part is the picker item, but the value reaches both the dot mark inside it and that item’s focus ring, so one override reshapes the two together. A tab keeps a square ring whatever this says, so an override has no effect in a tabs picker. |
radius | radius.md | 4px | 4px | no | Applied to the viewport so slide edges match the theme. |
tabColor | color.foreground.muted | #56585c | #bcbec1 | yes | part pickerItem: Text of an unselected tab in a tabs picker. |
tabSelectedColor | color.foreground.strong | #000000 | #ffffff | yes | part pickerItem. |
tabFontSize | font.size.sm | 13px | 13px | no | part pickerItem. |
tabFontWeight | font.weight.medium | 500 | 500 | no | part pickerItem: The same weight selected or not, so selection never shifts the row. |
tabLineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part pickerItem: The tab label’s line height, so a tabs picker keeps the row’s rhythm. It is a multiplier, so React Native, which needs an absolute value, resolves it against the effective tabFontSize: overriding the font size rescales the line height with it. |
tabPaddingBlock | space.sm | 8px | 8px | no | part pickerItem. |
tabPaddingInline | space.md | 12px | 12px | no | part pickerItem. |
tabIndicatorThickness | border.width.focus | 2px | 2px | yes | part pickerItem: The selected tab’s underline, drawn in dotActive inside the tab’s box (an inset, so it takes no layout). |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part pickerItem. |
transition | motion.duration.base | 200ms | 200ms | no | Dot background and tab text colour changes, with motion.easing.standard (weights never change, so nothing else animates); slide movement is the browser’s scroll-snap timing (instant under reduced motion). |
minTarget | size.target.comfortable | 44px | 44px | yes | The hit area of each arrow’s controlSurface — the part wrapper inside it stretches to fill that surface, while the composed Button keeps its own intrinsic size and is centred in it, since a child is never restyled; on web and Lit the extra area is the wrapper’s click-through, and on React Native, where the wrapper is inert, the pressable area is the Button’s alone — and the minimum block size of each tab, whose inline size comes from tabPaddingInline alone; dots use dotTarget. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes |
Checkbox
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
controlBackground | color.control.background | #ffffff | #2a2b2f | no | part control. |
controlBorder | color.control.border | #939599 | #707176 | yes | part control. |
controlBorderWidth | border.width.thin | 1px | 1px | no | part control. |
controlSelectedBackground | color.control.selectedBackground | #3553d2 | #496ded | yes | part control: Checked and indeterminate fill; the border takes the same color unless the box is invalid (see controlBorderInvalid). |
indicator | color.control.selectedForeground | #ffffff | #000000 | yes | part indicator: The check mark (Icon name="check", when checked) and the mixed dash (Icon name="dash", when the mixed indicator shows) in this color, at size: xs, centered in the control; nothing is rendered when unchecked. The color reaches the Icon only through its color override (a token path); the Checkbox hook does not style the Icon. The web/Lit control is a void , so the control and the indicator sit in a box wrapper span that stacks them in one cell: the input keeps data-part="control", and the indicator is an aria-hidden span with pointer-events: none over the input holding the Icon, carrying data-part="indicator" (web) / part="indicator" and data-part="indicator" (Lit). On native the Icon sits inside the drawn control View. |
indicatorStroke | border.width.focus | 2px | 2px | yes | part indicator: Stroke thickness of the check mark and dash. Locked, as Icon’s own strokeWidth is and on the same token, so the two already agree and nothing is forwarded into the composed Icon. It declares no —ds-checkbox-* hook on any platform: Icon already applies it. |
pressedOverlay | opacity.disabled | 0.5 | 0.5 | no | part control, state pressed: While pressed (web/Lit :active), an unchecked, not-mixed, enabled box shows controlSelectedBackground at this opacity over controlBackground; the border is unchanged. A checked or mixed box (already filled) and a disabled one show no overlay. Web/Lit: color-mix(in srgb, <controlSelectedBackground> calc(<pressedOverlay> * 100%), <controlBackground>) as the control background. Native: an absolutely filled overlay View inside the control, so the border does not fade. Sharing opacity.disabled with disabledOpacity is deliberate, not a placeholder: the theme has one “recede” opacity and both states use it, so a brand that retunes it moves the dim and the press tint together. |
controlBorderInvalid | color.border.danger | #b51d26 | #ff958d | no | part control: Border color while invalid (invalid or error), in every state: it replaces controlBorder on an unchecked box and the controlSelectedBackground-colored border on a checked or mixed box (the fill stays controlSelectedBackground). Border color precedence: invalid, then selected, then rest. Focus never hides it: on web/Lit the focus ring is a separate outline; on native, where focus is drawn on the border, a focused invalid box keeps this color and only the width changes to focusRingWidth, as in Input. |
controlSize | space.5 | 20px | 20px | no | part control. |
controlRadius | radius.sm | 2px | 2px | no | part control. |
gap | space.2 | 8px | 8px | no | part label: Horizontal gap between control and label (the row’s flex gap). The gap is part of the hit area: a press on it toggles. |
partGap | space.1 | 4px | 4px | no | part description: Vertical gap between label and description (the text column inside the row), and between the row and the error message below it (the root column’s gap). The error message is indented by controlSize + gap on every platform, as padding-inline-start on the message itself, never a margin, so it lines up with the label rather than the control. The indent is unconditional, hideLabel included, so the message keeps the same inset whether or not a label is visible. |
labelColor | color.foreground | #2a2b2f | #ecedee | yes | part label: Web/Lit: the native |
labelSize | font.size.md | 16px | 16px | no | part label: Web/Lit: the native |
labelWeight | font.weight.regular | 400 | 400 | no | part label: Web/Lit: the native |
helperSize | font.size.sm | 13px | 13px | no | part description: Description and error text size. Reaches the composed Texts only through their fontSize override, with fontFamily and lineHeight forwarded the same way; no —ds-checkbox-* hook, the documented exception to the rule that every binding gets one, because there is no element of Checkbox’s own to hang it on. fontFamily and lineHeight do keep their hooks, since they are also the label’s own rule. |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description: Realised by the composed Text’s muted tone; no —ds-checkbox-* hook. |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | part errorMessage: Realised by the composed Text’s danger tone; no —ds-checkbox-* hook. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part label: The label’s own rule on web/Lit (forwarded to the label Text on native), and forwarded to the description and error Texts. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part label: As fontFamily: the label’s own rule on web/Lit, forwarded to every composed Text. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part control: Web/Lit: an outline of focusRingWidth in this color around the control on :focus-visible. Native (hardware keyboard focus): the control’s border takes this color at focusRingWidth while focused (an invalid box keeps controlBorderInvalid and only the width changes); the box keeps its size, so the glyph area shrinks by the width difference. The focused width is never thinner than the rest width: it is the larger of focusRingWidth and controlBorderWidth, so a controlBorderWidth override wider than border.width.focus keeps its width on focus. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part control: Width of the focus ring, and on web and Lit its outline-offset as well, so the ring clears the control’s own border rather than sitting on it. |
minTarget | size.target.comfortable | 44px | 44px | yes | part control: Minimum height of the control + label row. It is the comfortable target, above the 24px floor target-24px asks for, so satisfying this binding satisfies the requirement everywhere. the whole row is the hit area, including the gap. The row aligns its children to the start of the cross axis, with paddingBlock max(0, (minTarget − labelSize × lineHeight) / 2) on each side — clamped at zero, so an override that makes the line box taller than the target grows the row instead of producing a negative padding — and minTarget as a minimum height, so a single-line row is exactly this tall. The control is not offset by a margin: its box is one label line box tall (labelSize × lineHeight) with the control centred inside it, which is how it lands on the label’s first line, and a wrapping label or a description grows the row downwards without pulling it off that line (as Switch). The construction is the same with hideLabel — the hidden label still sets the line box, so the control stays centred and the row keeps its height — which is also why hideLabel together with a description puts the control on the empty label line, above the description’s first line rather than beside it. The error message sits below the row, outside the hit area. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | part control: Dims the control (with its indicator) and the label. The description and error stay at full opacity so they remain readable. The dimmed label does not meet AA on its own, and contrast-aa and this binding coexist only because a disabled control is exempt: the control must therefore always report its disabled state to the platform (aria-disabled on web and Lit, and mirrored onto the DOM node under react-native-web), or the dim becomes a real contrast failure. |
transition | motion.duration.fast | 120ms | 120ms | no | part control: The control’s background (fill) and border color transitions in every state, with motion.easing.standard — the invalid and focus border colors are part of it, so entering and leaving those states cross-fades like a check does rather than snapping. The indicator is not animated: the check and dash Icons are mounted and unmounted (nothing is rendered when unchecked), so they appear, disappear and swap check↔dash instantly. Instant under reduced motion. |
Combobox
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
fieldBackground | color.background | #ffffff | #17181b | yes | part field. |
fieldBorder | color.border.strong | #939599 | #707176 | yes | part field. |
fieldBorderFocus | color.border.focus | #496ded | #99b5ff | yes | part field: While the input itself has focus (web and Lit: :has(input:focus-visible), not :focus-within, so a focused clear or chip-remove Button shows only its own ring). |
fieldBorderInvalid | color.border.danger | #b51d26 | #ff958d | no | part field. |
fieldBorderWidth | border.width.thin | 1px | 1px | no | part field. |
fieldRadius | radius.md | 4px | 4px | no | part field. |
fieldPaddingInline | space.md | 12px | 12px | no | part field. |
fieldPaddingBlock | space.sm | 8px | 8px | no | part field. |
fieldGap | layout.gap.tight | 4px | 4px | no | part field: Between chips, input text and the buttons. |
inputColor | color.foreground | #2a2b2f | #ecedee | yes | part input. |
placeholderColor | color.foreground.muted | #56585c | #bcbec1 | yes | part input: The input placeholder (::placeholder; placeholderTextColor on native). |
chipBackground | color.background.strong | #ecedee | #414246 | yes | part chip. |
chipColor | color.foreground | #2a2b2f | #ecedee | yes | part chip. |
chipRadius | radius.full | 999px | 999px | no | part chip. |
chipPaddingInline | space.2 | 8px | 8px | no | part chip. |
chipPaddingBlock | space.0 | 0px | 0px | no | part chip. |
chipGap | layout.gap.tight | 4px | 4px | no | part chip: Between chip label and its remove button. |
iconColor | color.foreground.muted | #56585c | #bcbec1 | yes | Toggle chevron, clear and chip-remove icons; forwarded to each composed Icon’s own color override as this token, on every platform — React Native included, where it goes to the Icon’s overrides.color rather than its color prop, so the combobox never resolves a token on the Icon’s behalf. Locked, it keeps its --ds-combobox-icon-color hook on web and Lit, as every locked binding does; the hook reaches each Icon by setting that Icon’s own documented --ds-icon-color hook on its host, never its internals. |
partGap | space.1 | 4px | 4px | no | Between label, description, field and error message. |
labelWeight | font.weight.medium | 500 | 500 | no | part label: Forwarded to the label Text’s own fontWeight override. |
helperSize | font.size.sm | 13px | 13px | no | Forwarded to the description and error Text’s own fontSize override. |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description: Also the colour of the status part where it is visible (React Native), which takes the description treatment — this token and helperSize — and has no binding of its own, so an override reaches it only through these two. |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | |
popupSurface | color.overlay.surface | #ffffff | #2a2b2f | no | part popup. |
popupBorder | color.border | #d9dbdd | #414246 | no | part popup. |
popupBorderWidth | border.width.thin | 1px | 1px | no | part popup. |
popupShadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | part popup. |
popupRadius | radius.md | 4px | 4px | no | part popup. |
popupOffset | space.1 | 4px | 4px | no | part popup: The gap between field and popup. Applied as the fixed-position popup’s block margin (it has no parent gap to use), as Select; only the side facing the field shows. |
layer | layer.dropdown | 100 | 100 | no | |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | Label and input text. |
fontSize | font.size.md | 16px | 16px | no | Label and input text. |
chipSize | font.size.sm | 13px | 13px | no | part chip. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | Label and input text. |
minTarget | size.target.comfortable | 44px | 44px | yes | part field: The field as a whole. |
inputMinTarget | size.target.min | 24px | 24px | yes | part input: The text input inside the field, which needs its own floor: with multiple the field grows to several chip rows and the input is only one of them. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part field: Replaces fieldBorderWidth while the input is focused (the field border is its focus ring, as Input); fieldPaddingInline and fieldPaddingBlock shrink by the difference so the content does not shift. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | |
enter | motion.duration.fast | 120ms | 120ms | no | Popup opacity fade-in and the field border-color transition; instant under reduced motion. |
Container
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
maxWidth | layout.maxWidth.prose | 572px | 572px | no | full renders no max-width — the literal none, with no hook, which also makes an override of this binding a no-op at that value; the binding covers the other three. No dev warning fires for an override that has no effect. |
maxWidth | layout.maxWidth.content | 960px | 960px | no | full renders no max-width — the literal none, with no hook, which also makes an override of this binding a no-op at that value; the binding covers the other three. No dev warning fires for an override that has no effect. |
maxWidth | layout.maxWidth.page | 1280px | 1280px | no | full renders no max-width — the literal none, with no hook, which also makes an override of this binding a no-op at that value; the binding covers the other three. No dev warning fires for an override that has no effect. |
maxWidth | layout.maxWidth.full | — | — | no | full renders no max-width — the literal none, with no hook, which also makes an override of this binding a no-op at that value; the binding covers the other three. No dev warning fires for an override that has no effect. |
paddingInline | layout.gutter.narrow | 16px | 16px | no | none renders no padding (a literal 0, with no hook), which also makes an override of this binding a no-op at that value, with no dev warning. narrow, wide and none are fixed at every viewport: their selectors carry the attribute or modifier class, so they outrank the bare responsive rules inside both media queries and no viewport can move them. Only default is responsive: layout.gutter.narrow below layout.maxWidth.content, layout.gutter.default (the --layout-gutter variable) from layout.maxWidth.content, and layout.gutter.wide from layout.maxWidth.page. Both boundaries are inclusive (>=, a min-width query), so a viewport exactly at a token width takes the wider gutter. An override of this binding replaces the value at every viewport width, including the whole responsive default gutter, not just its middle band. |
paddingInline | layout.gutter | 24px | 24px | no | none renders no padding (a literal 0, with no hook), which also makes an override of this binding a no-op at that value, with no dev warning. narrow, wide and none are fixed at every viewport: their selectors carry the attribute or modifier class, so they outrank the bare responsive rules inside both media queries and no viewport can move them. Only default is responsive: layout.gutter.narrow below layout.maxWidth.content, layout.gutter.default (the --layout-gutter variable) from layout.maxWidth.content, and layout.gutter.wide from layout.maxWidth.page. Both boundaries are inclusive (>=, a min-width query), so a viewport exactly at a token width takes the wider gutter. An override of this binding replaces the value at every viewport width, including the whole responsive default gutter, not just its middle band. |
paddingInline | layout.gutter.wide | 32px | 32px | no | none renders no padding (a literal 0, with no hook), which also makes an override of this binding a no-op at that value, with no dev warning. narrow, wide and none are fixed at every viewport: their selectors carry the attribute or modifier class, so they outrank the bare responsive rules inside both media queries and no viewport can move them. Only default is responsive: layout.gutter.narrow below layout.maxWidth.content, layout.gutter.default (the --layout-gutter variable) from layout.maxWidth.content, and layout.gutter.wide from layout.maxWidth.page. Both boundaries are inclusive (>=, a min-width query), so a viewport exactly at a token width takes the wider gutter. An override of this binding replaces the value at every viewport width, including the whole responsive default gutter, not just its middle band. |
paddingInline | layout.gutter.none | — | — | no | none renders no padding (a literal 0, with no hook), which also makes an override of this binding a no-op at that value, with no dev warning. narrow, wide and none are fixed at every viewport: their selectors carry the attribute or modifier class, so they outrank the bare responsive rules inside both media queries and no viewport can move them. Only default is responsive: layout.gutter.narrow below layout.maxWidth.content, layout.gutter.default (the --layout-gutter variable) from layout.maxWidth.content, and layout.gutter.wide from layout.maxWidth.page. Both boundaries are inclusive (>=, a min-width query), so a viewport exactly at a token width takes the wider gutter. An override of this binding replaces the value at every viewport width, including the whole responsive default gutter, not just its middle band. |
DataGrid
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
surface | color.background | #ffffff | #17181b | yes | part container. |
headerSurface | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part header. |
headerColor | color.foreground | #2a2b2f | #ecedee | yes | part header. |
headerWeight | font.weight.semibold | 600 | 600 | no | part header. |
headerSize | font.size.sm | 13px | 13px | no | part header. |
headerBorder | color.border.strong | #939599 | #707176 | no | part header. |
headerBorderWidth | border.width.thin | 1px | 1px | no | part header. |
headerShadow | shadow.raised | 0px 1px 3px 0px #0000001a | 0px 1px 3px 0px #00000059 | no | part header: Under the sticky header once the body has scrolled. |
gridLine | color.border | #d9dbdd | #414246 | no | part grid: Cell borders on both axes — a grid shows its cells, unlike Table which shows rows. |
gridLineWidth | border.width.thin | 1px | 1px | no | part grid. |
rowHeight | size.target.min | 24px | 24px | yes | part row: Compact rows are the minimum target height (rows are the unit people click); comfortable rows use rowHeightComfortable. Virtualization measures one rendered row (ResizeObserver) rather than reading the token, so density and overrides both work. Until a row has been measured (and always in jsdom) rows are positioned with calc(index × the row size) from the token and at most 50 render (literal-ok). A grid with a selection column is rowHeightComfortable at both densities, on every platform: the select Checkbox is a full minTarget and a compact row — the same minimum, less its grid lines — cannot hold one, so selectable: row and density: compact together give comfortable rows rather than a clipped or overlapping target. |
rowHeightComfortable | size.target.comfortable | 44px | 44px | yes | part row. |
rowHover | color.action.ghost.backgroundHover | #f6f7f8 | #2a2b2f | no | part row, state hover: Row under the pointer, the select cell included; the grid is interactive by nature so hover is allowed, and the focused cell ring is the non-hover signal. On native it is the pressed fill (and the react-native-web hover); a selected row keeps rowSelected. |
rowSelected | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part row. |
rowSelectedBorder | color.control.selectedBackground | #3553d2 | #496ded | yes | part row: Start-edge bar on selected rows. |
rowSelectedBorderWidth | border.width.focus | 2px | 2px | yes | part row. |
cellColor | color.foreground | #2a2b2f | #ecedee | yes | part cell. |
cellMutedColor | color.foreground.muted | #56585c | #bcbec1 | yes | part cell: Text of the existing body cells while loading (they stay but read as stale). |
cellPaddingInline | space.2 | 8px | 8px | no | part cell. |
selectColumnWidth | size.target.min | 24px | 24px | yes | part selectCell: The selection column’s total inline size is this plus 2 × cellPaddingInline, and the cell has no inline padding of its own — the Checkbox is centred in it, and Checkbox has no inset override to take that padding. Pinned-start offsets add the same total. |
columnWidth | space.20 | 80px | 80px | no | Width of a column that sets no width (160px); an override replaces the base, the × 2 stays in the rule. Where the base is exposed as a CSS hook the doubled value goes in a second, internal variable the consumer cannot set, so the hook keeps the bare token; the density-selected row height (rowHeight or rowHeightComfortable) resolves into an internal variable the same way. |
cellFocusRing | color.border.focus | #496ded | #99b5ff | yes | part cell: Drawn inside the cell (inset) so it is never clipped by neighbors or the scroll region. Shown on the active cell whenever the grid has focus, pointer focus included (on web :focus-within on the grid, since a clicked div is not :focus-visible). |
cellFocusRingWidth | border.width.focus | 2px | 2px | yes | part cell. |
cellEditingBackground | color.control.background | #ffffff | #2a2b2f | yes | part cell. |
cellEditingBorder | color.border.focus | #496ded | #99b5ff | yes | part cell. |
cellInvalidBorder | color.border.danger | #b51d26 | #ff958d | yes | part cell. |
cellInvalidBackground | color.status.danger.background | #fff2f0 | #4c0005 | yes | part cell. |
cellInvalidForeground | color.status.danger.foreground | #b51d26 | #ffc3bd | yes | part cell: The validation message in the status bar: a cellInvalidBackground span that re-scopes the foreground to this token (--color-foreground on web and Lit, TextForegroundContext on native) around a default-tone Text, since Text.color is locked (the claimed pair). The invalid cell itself holds the untouched editor and shows only cellInvalidBackground and cellInvalidBorder (an inset ring on native). |
rangeBackground | color.background.strong | #ecedee | #414246 | yes | part rangeOverlay: Selected range fill, drawn beneath the cell content (so the foreground-on-strong pair is exact); opaque pinned cells and a hovered row cover it. The border marks its edge on that same single overlay element, so all three bindings share the rangeOverlay part and the border is covered by those same pinned cells and hovered row — accepted, because splitting the overlay in two would leave rangeBackground on an element with no part. Range selection is web, lit and swiftui only — selectable: range degrades to row on rn, so the rangeOverlay part, these three bindings and copy.selectedRange have no element and no code there. |
rangeBorder | color.control.selectedBackground | #3553d2 | #496ded | yes | part rangeOverlay. |
rangeBorderWidth | border.width.focus | 2px | 2px | yes | part rangeOverlay. |
pinnedShadow | shadow.raised | 0px 1px 3px 0px #0000001a | 0px 1px 3px 0px #00000059 | no | part cell: Cast by pinned columns once the body has scrolled sideways: start pins on their end edge, end pins on their start edge. The token is applied unmirrored to both sides — mirroring shadow.raised would mean writing a literal offset — so the two sides cast the same shadow in the token’s own direction. |
resizeHandle | color.border.strong | #939599 | #707176 | no | part resizeHandle: The column edge grab area (role=separator, not focusable; keyboard resizing is Shift+Arrow on the header cell), visible on hover/focus of the header cell. Its aria-valuenow is set once the column has a pixel width — an explicit width or one the user has resized — and omitted while the column sits at the columnWidth token, whose pixel value the grid does not know at render. Its aria-valuemin is the column’s minWidth; a column has no maximum, so aria-valuemax is omitted. Native has no hover, so it is always visible there, hit-slopped to size.target.min. |
resizeHandleWidth | space.1 | 4px | 4px | no | part resizeHandle. |
resizeStep | space.4 | 16px | 16px | no | Width change per Shift+Arrow press on a resizable header cell. Pointer and keyboard resizing both need this and minTarget as pixel numbers; a platform that cannot read a token’s computed length measures it from a hidden, zero-height probe element instead of hard-coding one, so an override still moves the step. |
statusBarSurface | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part statusBar: The statusBar part stays on the live status Text (the composed Text that carries statusBarSize); statusBarSurface, statusBarPadding and statusBarGap style the bar element the grid owns around that Text and its sibling Texts, which carries no part of its own. |
statusBarColor | color.foreground.muted | #56585c | #bcbec1 | yes | part statusBar: The status bar Texts use tone="muted"; Text.color is locked, so this is not forwarded. |
statusBarSize | font.size.xs | 11px | 11px | no | part statusBar. |
statusBarPadding | space.2 | 8px | 8px | no | part statusBar. |
statusBarGap | space.2 | 8px | 8px | no | part statusBar: Between status bar items, which are laid out in this order and no other: the live message, the row count, the selection count, the scroll hint, the position. No separator characters. The row count is always shown; the selection count only while something is selected; the scroll hint only while the columns overflow the region and it has not yet been scrolled sideways; the position only while the active cell is a body cell — the first header cell is active on entry and has no row number to interpolate, so nothing is shown until focus reaches the body. On rn a cell becomes active only in cell mode (a tap makes it active there); in row and none no cell is ever active, no body cell is pressable, and the position is absent. |
captionSize | font.size.md | 16px | 16px | no | part caption. |
captionWeight | font.weight.semibold | 600 | 600 | no | part caption. |
captionGap | space.2 | 8px | 8px | no | part caption: Below the caption; the Heading’s own margin is turned off with overrides={{ marginBlockEnd: 'space.0' }}, as Table. |
fixedHeight | space.20 | 80px | 80px | no | The height for height: fixed. It sizes the whole component — caption, scroll region and status bar — as viewport does, and the scroll region takes what the caption and status bar leave. Overridable by design (a page decides how tall its grid is); space.20 is the floor, not a recommendation. It is deliberately shorter than the chrome plus one row, so height: fixed is only meaningful with an overrides.fixedHeight: a fixed grid without one renders caption, header and status bar and no visible rows, which is the binding working as specified rather than a default to compensate for. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | The component’s font, set on the container and inherited by the scroll region and the cells. |
fontSize | font.size.sm | 13px | 13px | no | Set with fontFamily on the container. |
lineHeight | font.lineHeight.tight | 1.2 | 1.2 | no | Set with fontFamily on the container. |
numericFont | font.family.mono | ui-monospace, SFMono-Regular, Menlo, Consolas, monospace | ui-monospace, SFMono-Regular, Menlo, Consolas, monospace | no | part cell: Cells whose raw value is a number and that have no render. |
minTarget | size.target.min | 24px | 24px | yes | The floor for column resize (a column’s minWidth never goes below it) and the size of the Checkbox the selection column composes: forwarded to Checkbox’s controlSize and set on the Checkbox itself, since a declaration on an ancestor loses to the child’s own default. A select cell is therefore a full minimum target, and two select cells in neighbouring rows clear the target-size spacing rule. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part scrollRegion: Drawn by the scroll region while the grid inside it has focus, so the ring is never clipped by the region’s own overflow. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part scrollRegion. |
transition | motion.duration.fast | 120ms | 120ms | no | Hover and editor open; navigation and scrolling are instant. |
DatePicker
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
background | color.background | #ffffff | #17181b | yes | |
foreground | color.foreground | #2a2b2f | #ecedee | yes | |
placeholder | color.foreground.muted | #56585c | #bcbec1 | yes | |
border | color.border.strong | #939599 | #707176 | yes | |
borderFocus | color.border.focus | #496ded | #99b5ff | yes | |
borderInvalid | color.border.danger | #b51d26 | #ff958d | no | |
borderWidth | border.width.thin | 1px | 1px | no | |
radius | radius.md | 4px | 4px | no | |
paddingInline | space.md | 12px | 12px | no | The input’s inline padding. An override replaces the value at both sizes. |
paddingInline (size=sm) | space.2 | 8px | 8px | no | The input’s inline padding. An override replaces the value at both sizes. |
paddingBlock | space.sm | 8px | 8px | no | The input’s block padding. An override replaces the value at both sizes. |
paddingBlock (size=sm) | space.1 | 4px | 4px | no | The input’s block padding. An override replaces the value at both sizes. |
fontSize | font.size.sm | 13px | 13px | no | The field text and the label (font.size.md at md, font.size.sm at sm); description and error use helperSize. |
fontSize | font.size.md | 16px | 16px | no | The field text and the label (font.size.md at md, font.size.sm at sm); description and error use helperSize. |
rangeSeparatorColor | color.foreground.muted | #56585c | #bcbec1 | yes | The en dash between start and end inputs. The dash is decoration, not an anatomy part: it is an aria-hidden span (Text on native) with no data-part or testID, so this binding’s own hook is the only way to reach it. |
calendarSurface | color.overlay.surface | #ffffff | #2a2b2f | yes | Not forwarded: Popover’s and BottomSheet’s surface is locked to this same token, so the composed overlay’s own surface realises it and an override has no effect. |
calendarInset | layout.inset.md | 12px | 12px | no | Forwarded to the composed Popover as overrides.inset (web, lit) and to the BottomSheet as overrides.inset (rn), so the sheet pads at this value rather than its own layout.inset.lg. |
calendarGap | layout.gap.normal | 8px | 8px | no | Between header, grid and footer. |
headerGap | layout.gap.tight | 4px | 4px | no | part header: Between the prev/next Buttons and the month and year Selects in the header row. The header’s order in the document is the Tab order — previous month, month Select, year Select, next month — which is not the order the anatomy list happens to use. |
footerGap | layout.gap.tight | 4px | 4px | no | part footer: Between the Today and Clear Buttons. The footer row is aligned to the inline end, so it reads as an action row under the grid. |
daySize | size.target.comfortable | 44px | 44px | yes | part day: Every day cell is a comfortable square target. |
dayGap | space.0 | 0px | 0px | no | part day: Cells touch, so a range reads as one bar; the selected day’s radius gives it shape. |
dayRadius | radius.md | 4px | 4px | no | part day. |
dayHover | color.action.ghost.backgroundHover | #f6f7f8 | #2a2b2f | no | part day, state hover: Hover background of a day cell. On React Native hover exists only under react-native-web (Pressable onHoverIn/onHoverOut); the binding is wired there and is dead on a device, as every other hover binding is. |
daySelectedBackground | color.control.selectedBackground | #3553d2 | #496ded | yes | part day. |
daySelectedForeground | color.control.selectedForeground | #ffffff | #000000 | yes | part day. |
dayInRangeBackground | color.background.strong | #ecedee | #414246 | yes | part day. |
dayTodayBorder | color.control.selectedBackground | #3553d2 | #496ded | yes | part day: The today ring. On a selected or in-range today the ring stays and the selected (or in-range) fill takes the background; on a selected today the ring is the fill’s own colour and so not visible, which is accepted: the selected state wins visually and today is still announced (aria-current, copy.todayLabel). While the day has focus the focus ring replaces it. |
dayTodayBorderWidth | border.width.focus | 2px | 2px | yes | part day. |
dayOutsideMonthColor | color.foreground.muted | #56585c | #bcbec1 | yes | part day. |
weekdayColor | color.foreground.muted | #56585c | #bcbec1 | yes | part weekdayHeader. |
weekdaySize | font.size.xs | 11px | 11px | no | part weekdayHeader: Weekday header cells. Each of the seven header cells carries the weekdayHeader part (so a query for it returns seven, as day and weekNumber do), never the header row. |
weekdayWeight | font.weight.medium | 500 | 500 | no | part weekdayHeader. |
weekNumberSize | font.size.xs | 11px | 11px | no | part weekNumber: Week-number cells, in weekdayColor at weekNumberWeight. The week column’s header cell carries no data-part, so it takes the weekdayHeader styling rather than the week-number styling — it is a weekday-row cell that happens to name the week column. |
weekNumberWeight | font.weight.regular | 400 | 400 | no | part weekNumber: Week-number cells’ weight. |
monthTitleSize | font.size.md | 16px | 16px | no | Forwarded to the month and year Selects as overrides.fontSize. |
monthTitleWeight | font.weight.semibold | 600 | 600 | no | Forwarded to the month and year Selects as overrides.fontWeight. |
partGap | space.1 | 4px | 4px | no | Between label, description, field and error. |
fieldGap | space.2 | 8px | 8px | no | part field: The field row’s gap, applied uniformly: between the input(s) and the calendar button, and on both sides of a range’s en-dash separator, so the dash sits at the same distance as the button. |
dayFontSize | font.size.sm | 13px | 13px | no | part day. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | The field text’s line height. It does not reach the description or errorMessage Texts — only helperSize and fontFamily are forwarded to them, and a Text’s line height is otherwise its own. |
labelWeight | font.weight.medium | 500 | 500 | no | part label. |
helperSize | font.size.sm | 13px | 13px | no | |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description. |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | |
minTarget | size.target.comfortable | 44px | 44px | yes | |
minTargetSm | size.target.min | 24px | 24px | yes | The field height floor at size sm; the calendar Button becomes size sm. The calendar popup is unchanged. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | The field’s focus-visible ring and the focused day’s ring, which replaces the today ring while that day has focus. The day has no ring binding of its own: it borrows this one at focusRingWidth, drawn with the today ring’s geometry (inset on React Native, where there is no outline) so the two never shift the cell. |
focusRingWidth | border.width.focus | 2px | 2px | yes | |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | |
transition | motion.duration.fast | 120ms | 120ms | no | Day-cell hover and selection states animate background-color, color and (web, lit) the today ring’s border-color; a month change is instant. Instant under reduced motion. On React Native it is background colour only, through one fill layer’s opacity: the fill fades in when it appears and out when it goes, while a fill whose colour merely changes (hover to selected, selected to in-range) switches at once, as the day text colour does — cross-fading two background colours would need a second layer per state. The today ring there is an inset ring, not a border, and switches at once. |
Dialog
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
scrim | color.overlay.scrim | #00000066 | #00000099 | no | part scrim. |
surface | color.overlay.surface | #ffffff | #2a2b2f | yes | part surface. |
border | color.border | #d9dbdd | #414246 | no | Hairline; the only edge in a flat theme. |
borderWidth | border.width.thin | 1px | 1px | no | |
shadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | |
radius | radius.lg | 6px | 6px | no | |
inset | layout.inset.lg | 16px | 16px | no | Inline padding of header, body and footer, plus the block padding of the surface column that holds them (once at the top, once at the bottom), so nothing doubles between parts. The header and the footer wrapper apply the inline padding themselves; the body Box receives it as overrides.paddingInline and keeps zero block padding. The forward always reaches the Box: on web and Lit through the stylesheet setting the Box’s --ds-box-padding-inline to --ds-dialog-inset — that hook is the whole delivery, since an element-level overrides.paddingInline would only set the same custom property again; on rn the Box takes inset="none" and the token path (the override, else layout.inset.lg) in overrides.paddingInline, a path and never a resolved value, since a parent does not resolve a token for its child. |
partGap | layout.gap.loose | 16px | 16px | no | Gap between header, body and footer, and the only space between them. |
gutter | layout.gutter | 24px | 24px | no | Minimum space between the surface and the viewport edge: web and Lit cap the surface at 100vw - 2 × gutter wide and 100dvh - 2 × gutter tall — never the |
headerGap | layout.gap.normal | 8px | 8px | no | part header: Between title/description and the close button. |
footerGap | layout.gap.tight | 4px | 4px | no | part footer: Between footer actions; forwarded to the footer Stack as overrides.gap. On web and Lit the CSS hook reaches the Stack too: the footer wrapper’s stylesheet sets the Stack’s own --ds-stack-gap hook on the Stack element to --ds-dialog-footer-gap, so either route changes the gap. The footer row is end-aligned (Form’s action-row rule), unlike Card’s start-aligned footer. |
descriptionGap | layout.gap.tight | 4px | 4px | no | part header: Between the heading and the description: the flex gap of the titles group, a Dialog-owned element inside the header that wraps heading and description. The group is not an anatomy part and carries no data-part or testID. |
widthSm | layout.maxWidth.prose | 572px | 572px | no | Surface width for size sm. |
widthMd | layout.maxWidth.content | 960px | 960px | no | Surface width for size md: layout.maxWidth.content × 0.75, not a new token. An override replaces the base; the × 0.75 stays in the rule, and the rule multiplies the hook (--ds-dialog-width-md, itself defaulting to the token) rather than the raw token, or an override would never reach it. |
widthLg | layout.maxWidth.content | 960px | 960px | no | Surface width for size lg. |
layer | layer.dialog | 300 | 300 | no | Kept as the hook, but it has no effect inside the browser top layer or a native Modal window; it applies to a non-top-layer fallback (position: fixed) only. On rn it is still written as zIndex on the centring container, as AlertDialog, BottomSheet and SidePanel do, so the override is not dead code on that platform — a Modal window simply ignores it. |
enter | motion.duration.base | 200ms | 200ms | no | Scrim fade and surface fade-and-rise, motion.easing.standard: the surface starts space.2 below its resting place (translateY +space.2 → 0) and rises into it; the scrim fades in with the same duration and easing. Runs also when the dialog mounts already open. Instant under reduced motion. |
exit | motion.duration.fast | 120ms | 120ms | no | Fade only, with motion.easing.exit; the scrim fades out with the same duration and easing as the surface. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part heading: The ring on the heading when it holds focus (tabindex -1), drawn by the Dialog-owned heading wrapper with :has(:focus-visible) — Heading has no focus style of its own. :focus-visible is the intent, not an accident of the programmatic focus: a dialog opened from the keyboard shows the ring, one opened by mouse does not, which is the same rule every other control follows. Web and Lit only: rn draws no ring, the platform’s screen-reader focus indicator stands in. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part heading. |
Disclosure
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
triggerColor | color.foreground | #2a2b2f | #ecedee | yes | part trigger. |
triggerBackgroundHover | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part trigger, state hover: Pointer hover and pressed state of the trigger — web and Lit apply it on :hover and :active, RN on onHoverIn/onHoverOut or pressed; suppressed while disabled. The background change is instant (no transition). At rest the trigger has no background: the literal transparent is the sanctioned value there, since no token expresses the absence of a fill. |
triggerPaddingBlock | space.sm | 8px | 8px | no | part trigger. |
triggerPaddingInline | space.sm | 8px | 8px | no | part trigger. |
triggerGap | space.2 | 8px | 8px | no | part trigger: Gap between icon and summary. |
triggerFontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part trigger. |
triggerFontSize | font.size.md | 16px | 16px | no | part trigger. |
triggerFontWeight | font.weight.medium | 500 | 500 | no | part trigger. |
triggerLineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part trigger: Line height of the summary text, with its own override hook like the other trigger type bindings. |
triggerRadius | radius.md | 4px | 4px | no | part trigger. |
icon | color.foreground.muted | #56585c | #bcbec1 | yes | part triggerIcon: Colour of the triggerIcon part: a wrapper the Disclosure owns (a span on web/Lit, a View on native) that carries this colour and the rotation, around the system Icon name="chevron-right" (decorative, aria-hidden), which keeps its own hook and is not restyled. The wrapper lays out as display: inline-flex; flex: none on web and Lit — it centres the 1em glyph without the unitless line-height: 0 trick and leaves the trigger’s height untouched. Rotated 90° when open. On web and Lit the Icon is inline, so it follows the trigger’s font size (including a triggerFontSize override). On native the Icon is not inline (an inline Icon ignores size) and its overrides.size receives the triggerFontSize token (default font.size.md). Mirrored in right-to-left writing on every platform ([dir=rtl] → scaleX(-1) on web, :host(:dir(rtl)) on Lit — a shadow root cannot match an ancestor’s dir attribute, so the :dir() pseudo-class is the Lit floor — composed as scaleX(-1) rotate(90deg) when open; I18nManager.isRTL → chevron-left on native). On native the chevron’s overrides.size is the triggerFontSize binding’s own token path, read from the binding rather than restated, so the two cannot drift. |
panelPaddingBlock | space.sm | 8px | 8px | no | part panel. |
panelPaddingInline | space.sm | 8px | 8px | no | part panel. |
panelColor | color.foreground | #2a2b2f | #ecedee | yes | part panel. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part trigger: On native, where the ring is a border rather than an outline, the border is always present at focusRingWidth and merely transparent while unfocused, so taking focus never shifts the trigger. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part trigger. |
minTarget | size.target.min | 24px | 24px | yes | part trigger: Applied as both a minimum height and a minimum width on the trigger. That alone satisfies target-24px: with the trigger padding the box already exceeds the floor, so no hitSlop is added on native. The trigger is start-aligned in its column (alignSelf flex-start on native, inline-level on web), so the hover fill hugs the summary instead of spanning the parent. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | part trigger: Dims the trigger only; the panel keeps full opacity. |
transition | motion.duration.base | 200ms | 200ms | no | part triggerIcon: Chevron rotation only, with motion.easing.standard; instant under reduced motion (on native the first placement of the chevron is always set directly, so nothing animates on first render; later changes animate unless useReducedMotion reports true at that moment). The panel itself does not animate height, and the trigger hover background has no transition. |
Divider
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
color | color.border | #d9dbdd | #414246 | no | |
thickness | border.width.thin | 1px | 1px | no | |
spacing | layout.gap.none | 0px | 0px | no | spacing: none is the off state: it renders no space and sets no hook (not a layout.gap.none value, and an overrides.spacing is not written to the hook either), so an override of this binding does nothing until a spacing value is chosen — overrides change values, never presence. |
spacing | layout.gap.tight | 4px | 4px | no | spacing: none is the off state: it renders no space and sets no hook (not a layout.gap.none value, and an overrides.spacing is not written to the hook either), so an override of this binding does nothing until a spacing value is chosen — overrides change values, never presence. |
spacing | layout.gap.normal | 8px | 8px | no | spacing: none is the off state: it renders no space and sets no hook (not a layout.gap.none value, and an overrides.spacing is not written to the hook either), so an override of this binding does nothing until a spacing value is chosen — overrides change values, never presence. |
spacing | layout.gap.loose | 16px | 16px | no | spacing: none is the off state: it renders no space and sets no hook (not a layout.gap.none value, and an overrides.spacing is not written to the hook either), so an override of this binding does nothing until a spacing value is chosen — overrides change values, never presence. |
labelColor | color.foreground.muted | #56585c | #bcbec1 | yes | part label: Locked, and deliberately not enforced by Divider: the colour arrives from the composed Text’s tone: muted, which resolves to this same token, and writing a colour rule here would restyle a composed child. The contrast pair is therefore a claim about Text’s muted tone on the page background, which no Divider code can regress. |
labelSize | font.size.sm | 13px | 13px | no | part label: Passed to the composed Text as its fontSize override, along with fontFamily; Divider does not style the Text itself. The label color and default size come from the Text props size="sm" tone="muted", so Divider writes no label color or size rule of its own. Neither labelSize nor fontFamily has a —ds-divider-* hook on web or Lit: only the bindings the author overrode are passed on, into Text’s overrides, and page CSS reaches the label through Text’s own hooks. |
labelGap | layout.gap.normal | 8px | 8px | no | part label: Gap between the label and the lines on each side: the gap of the labelled root row (flex gap on web and Lit, the gap style on React Native), not a composed Stack. With no label in effect there is no row, and an override of it does nothing. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part label: Reaches only the composed label Text, through its fontFamily override; the line has no text, so the root sets no font hook. |
Feed
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
itemGap | layout.gap.normal | 8px | 8px | no | |
articleInset | layout.inset.md | 12px | 12px | no | part article: Each Card keeps inset="md"; the value is forwarded to the Card’s overrides.paddingBlock and overrides.paddingInline (Card’s inset is an sm/md/lg choice and takes no token). |
articleBodyGap | layout.gap.tight | 4px | 4px | no | part articleBody: Between the runs of articleBody (unread word, timestamp, position, content); articleBody is a Stack and this is forwarded as its overrides.gap. |
unreadBorder | color.control.selectedBackground | #3553d2 | #496ded | yes | part article: Start-edge bar on unread articles, paired with the visually-hidden “unread” word. The article part is a Feed-owned wrapper around each Card, and the bar is drawn by that wrapper over the Card’s start edge (mirrored in RTL); the Card itself is not restyled. On React Native there is no overlay without a negative offset, so the wrapper draws a start border instead, which insets the Card by the bar’s width. Read articles reserve nothing in its place, so on that platform an unread row’s Card starts unreadBorderWidth further in than a read one; the shift is accepted rather than padded away, since padding every read row would inset the whole feed for the sake of a few marks. |
unreadBorderWidth | border.width.focus | 2px | 2px | yes | part article. |
articleRadius | radius.lg | 6px | 6px | no | part article: Corner radius of the Feed-owned article wrapper, matching Card’s own default radius. It matters where the wrapper draws something of its own: on Lit the wrapper is the article and draws the focus ring, which would otherwise be a square around a rounded Card; on web and React Native it only rounds the ends of the unread bar. |
timestampColor | color.foreground.muted | #56585c | #bcbec1 | yes | part timestamp: Expressed as the composed Text’s tone="muted", not a colour set on the Text. |
timestampSize | font.size.xs | 11px | 11px | no | part timestamp: The composed Text’s size="xs"; an override is forwarded to the Text’s overrides.fontSize. The timestamp part hook sits on the <time> element inside that Text, so the size applies to the wrapping Text rather than to the part element — as the endMessage and emptyState sizes apply to the Text inside their part wrapper. On React Native the part hook sits on a wrapping View instead (Text takes no testID) and both size="xs" and the override go to the Text inside it, so the part element is again not the styled one. |
newItemsOffset | space.3 | 12px | 12px | no | Padding-block-start of the sticky new-items row (it is the first child, so padding, not a margin). On React Native the row is a View above the FlatList, not a sticky header inside it, so it never scrolls away. |
newItemsLayer | layer.raised | 1 | 1 | no | part newItemsButton: Stacking order of the sticky new-items row over the scrolling articles (web and Lit). On React Native the row is a sibling View above the FlatList with nothing to overlap, so the value changes nothing there; it is still applied as zIndex, which under react-native-web does stack, so an override resolves. |
loadingInset | layout.inset.md | 12px | 12px | no | part loadingIndicator: Padding of the Feed-owned loadingIndicator wrapper around the ProgressBar. |
endMessageInset | layout.inset.md | 12px | 12px | no | part endMessage: Padding of the Feed-owned endMessage wrapper around the Text; the wrapper carries the part hook. |
endMessageColor | color.foreground.muted | #56585c | #bcbec1 | yes | part endMessage: Expressed as the composed Text’s tone="muted". |
endMessageSize | font.size.sm | 13px | 13px | no | part endMessage: The composed Text’s size="sm"; an override is forwarded to the Text’s overrides.fontSize. |
emptyStateInset | layout.inset.md | 12px | 12px | no | part emptyState: Padding of the Feed-owned emptyState wrapper around the Text, as endMessageInset. |
emptyStateColor | color.foreground.muted | #56585c | #bcbec1 | yes | part emptyState: Expressed as the composed Text’s tone="muted". |
emptyStateSize | font.size.sm | 13px | 13px | no | part emptyState: The composed Text’s size="sm"; an override is forwarded to the Text’s overrides.fontSize. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | The root font (a CSS hook inherited by caller content on web and Lit), also forwarded to the overrides.fontFamily of every composed Text (timestamp, end message, empty state, hidden runs, wrapped string content on native) and of the new-items Button. Card’s heading keeps Card’s own font. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | Child-owned on web and React Native: the article ring is drawn by the composed Card and the button ring by the composed Button, and the binding only names the token a theme has to keep in step. On Lit the article wrapper is itself the focus target (the Card cannot be focusable there), so Feed draws that ring — an outline in this colour over articleRadius corners, with no offset, mirroring Card’s own focus treatment. |
focusRingWidth | border.width.focus | 2px | 2px | yes | Width of that ring, on the same elements: child-owned on web and React Native, the wrapper’s outline width on Lit. |
Fieldset
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
legendColor | color.foreground | #2a2b2f | #ecedee | yes | part legend: Realised by the composed legend Text’s default tone; no —ds-fieldset-* hook. |
legendSize | font.size.md | 16px | 16px | no | part legend: Reaches the legend Text only through its overrides.fontSize; no —ds-fieldset-* hook. |
legendWeight | font.weight.medium | 500 | 500 | no | part legend: Reaches the legend Text only through its overrides.fontWeight; no —ds-fieldset-* hook. |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description: Realised by the composed description Text’s muted tone; no —ds-fieldset-* hook. |
helperSize | font.size.sm | 13px | 13px | no | part description: Description and error text size. Reaches both Texts only through their overrides.fontSize; no —ds-fieldset-* hook. |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | part errorMessage: Realised by the composed error Text’s danger tone; no —ds-fieldset-* hook. |
partGap | layout.gap.tight | 4px | 4px | no | part group: Vertical gap between legend, description, fields and error, set on the group root. On web and Lit the |
fieldsGap | layout.gap.tight | 4px | 4px | no | part fields: The composed Stack’s gap. It reaches the Stack only as a token path through the Stack’s own overrides.gap, always sent (the override, else layout.gap.{gap}), and the Stack gets no gap prop or attribute. No —ds-fieldset-* hook (a Fieldset hook would not reach the Stack), and Fieldset does not set the Stack’s CSS hooks (—ds-stack-gap) either; page CSS sizes the gap through the Stack’s own hooks. Because no gap prop is passed, the Stack keeps its own default gap state and its modifier class or attribute still reads normal whatever Fieldset’s gap is: the override wins visually, and the rendered gap is read from the Stack’s hook, never from that class. |
fieldsGap | layout.gap.normal | 8px | 8px | no | part fields: The composed Stack’s gap. It reaches the Stack only as a token path through the Stack’s own overrides.gap, always sent (the override, else layout.gap.{gap}), and the Stack gets no gap prop or attribute. No —ds-fieldset-* hook (a Fieldset hook would not reach the Stack), and Fieldset does not set the Stack’s CSS hooks (—ds-stack-gap) either; page CSS sizes the gap through the Stack’s own hooks. Because no gap prop is passed, the Stack keeps its own default gap state and its modifier class or attribute still reads normal whatever Fieldset’s gap is: the override wins visually, and the rendered gap is read from the Stack’s hook, never from that class. |
fieldsGap | layout.gap.loose | 16px | 16px | no | part fields: The composed Stack’s gap. It reaches the Stack only as a token path through the Stack’s own overrides.gap, always sent (the override, else layout.gap.{gap}), and the Stack gets no gap prop or attribute. No —ds-fieldset-* hook (a Fieldset hook would not reach the Stack), and Fieldset does not set the Stack’s CSS hooks (—ds-stack-gap) either; page CSS sizes the gap through the Stack’s own hooks. Because no gap prop is passed, the Stack keeps its own default gap state and its modifier class or attribute still reads normal whatever Fieldset’s gap is: the override wins visually, and the rendered gap is read from the Stack’s hook, never from that class. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | part legend: Dims the legend and description only while disabled, applied as opacity on the Fieldset-owned legend and description elements (web/Lit wrappers, RN Fieldset.legend and Fieldset.description Views), never on the Texts. The group error is never dimmed, and the fields dim themselves, so the group root and the fields wrapper are never dimmed. Every element that carries this opacity also reports the disabled state on itself (aria-disabled on web and Lit, and on the two RN wrapper Views under react-native-web): the dim drops the legend below 4.5:1, and that is only permissible because an inactive control is exempt, which nothing can tell without the attribute. It is one of the two bindings with a —ds-fieldset-* hook (—ds-fieldset-disabled-opacity). |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part legend: Reaches the legend, description and error Texts only through their overrides.fontFamily (part names the first; the composition forwards decide where it goes); no —ds-fieldset-* hook. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part legend: Reaches the legend, description and error Texts only through their overrides.lineHeight (part names the first; the composition forwards decide where it goes); no —ds-fieldset-* hook. |
FocusScope
No style bindings.
Form
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
gap | layout.gap.loose | 16px | 16px | no | Vertical gap between the direct children of the fields part and between fields and actions — the rhythm preset, so a theme’s layout.rhythm reaches every form. A Stack given as children spaces its own fields with its own gap; Form does not reach inside it. |
errorSummaryBorder | color.border.danger | #b51d26 | #ff958d | no | part errorSummary. |
errorSummaryText | color.foreground.danger | #b51d26 | #ff958d | yes | part errorSummary. |
errorSummaryBackground | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part errorSummary. |
errorSummaryBorderWidth | border.width.thin | 1px | 1px | no | part errorSummary. |
errorSummaryRadius | radius.md | 4px | 4px | no | part errorSummary. |
errorSummaryLineHeight | font.lineHeight.normal | 1.5 | 1.5 | yes | part errorSummary: Line height of the summary box, inherited by the item Links. Without it they take the document’s normal and each link’s box falls under the 24px target floor — Form may not give the Links a target of their own, so the box sets the body line height instead and the Links inherit it. Locked because it is the only thing keeping those targets reachable. |
errorSummaryPadding | space.md | 12px | 12px | no | part errorSummary: Inset on all four sides of the summary box. |
errorSummaryGap | layout.gap.tight | 4px | 4px | no | part errorSummary: Gap between the heading and the list and between list items. Inside the errorSummary box a Stack holds the heading and the list, and the list is a second Stack (element ul on web and Lit) with no markers and no indent; Form forwards this binding to both Stacks’ overrides.gap (the overrides property on Lit) and has no —ds-form-* hook for it, as Input’s helperSize has none. The item Links are passed to the list Stack bare: Stack wraps each child in its own li. Having no hook of its own, it is the one binding a consumer cannot reach from their own CSS — they size it through the Stacks’ own hooks, as with Fieldset’s fieldsGap. |
Heading
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
fontFamily | font.family.heading | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | |
fontWeight | font.weight.semibold | 600 | 600 | no | |
fontSize | font.size.4xl | 40px | 40px | no | |
fontSize | font.size.3xl | 33px | 33px | no | |
fontSize | font.size.2xl | 28px | 28px | no | |
fontSize | font.size.xl | 23px | 23px | no | |
fontSize | font.size.lg | 19px | 19px | no | |
fontSize | font.size.md | 16px | 16px | no | |
lineHeight | font.lineHeight.tight | 1.2 | 1.2 | no | |
color | color.foreground.strong | #000000 | #ffffff | yes | Locked by the AAA pair, so it is absent from the overrides type; it keeps its --ds-heading-color hook, defaulting to var(--color-foreground-strong), and the rule reads the hook, as every locked binding does. |
marginBlockEnd | space.sm | 8px | 8px | no | Space below the heading (marginBottom on React Native — the one margin the system allows, because a heading owns the gap to its own first paragraph). It is unconditional: a container that owns its own rhythm turns it off with overrides={{ marginBlockEnd: 'space.0' }}, as Table does. That override is written out as a margin of 0, never omitted. |
Icon
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
size | font.size.xs | 11px | 11px | no | The glyph box is a square of 1em; size sets that em (font-size on the element), so the box tracks the type scale. With inline, font-size is inherited instead and overrides.size is a no-op (the hook is still set, for consistency, and on web the ds-icon--{size} modifier class stays applied; .ds-icon--inline is declared later and wins). A non-inline overrides.size is written as the --ds-icon-size hook inline, so it outranks the ds-icon--{size} modifier’s declaration of the same hook. React Native has no hook, so there the override is simply ignored while inline. Non-inline icons are display inline-block with vertical-align middle (the Lit host is inline-flex instead; see its note); inline ones sit at vertical-align -0.125em. |
size | font.size.sm | 13px | 13px | no | The glyph box is a square of 1em; size sets that em (font-size on the element), so the box tracks the type scale. With inline, font-size is inherited instead and overrides.size is a no-op (the hook is still set, for consistency, and on web the ds-icon--{size} modifier class stays applied; .ds-icon--inline is declared later and wins). A non-inline overrides.size is written as the --ds-icon-size hook inline, so it outranks the ds-icon--{size} modifier’s declaration of the same hook. React Native has no hook, so there the override is simply ignored while inline. Non-inline icons are display inline-block with vertical-align middle (the Lit host is inline-flex instead; see its note); inline ones sit at vertical-align -0.125em. |
size | font.size.md | 16px | 16px | no | The glyph box is a square of 1em; size sets that em (font-size on the element), so the box tracks the type scale. With inline, font-size is inherited instead and overrides.size is a no-op (the hook is still set, for consistency, and on web the ds-icon--{size} modifier class stays applied; .ds-icon--inline is declared later and wins). A non-inline overrides.size is written as the --ds-icon-size hook inline, so it outranks the ds-icon--{size} modifier’s declaration of the same hook. React Native has no hook, so there the override is simply ignored while inline. Non-inline icons are display inline-block with vertical-align middle (the Lit host is inline-flex instead; see its note); inline ones sit at vertical-align -0.125em. |
size | font.size.lg | 19px | 19px | no | The glyph box is a square of 1em; size sets that em (font-size on the element), so the box tracks the type scale. With inline, font-size is inherited instead and overrides.size is a no-op (the hook is still set, for consistency, and on web the ds-icon--{size} modifier class stays applied; .ds-icon--inline is declared later and wins). A non-inline overrides.size is written as the --ds-icon-size hook inline, so it outranks the ds-icon--{size} modifier’s declaration of the same hook. React Native has no hook, so there the override is simply ignored while inline. Non-inline icons are display inline-block with vertical-align middle (the Lit host is inline-flex instead; see its note); inline ones sit at vertical-align -0.125em. |
size | font.size.xl | 23px | 23px | no | The glyph box is a square of 1em; size sets that em (font-size on the element), so the box tracks the type scale. With inline, font-size is inherited instead and overrides.size is a no-op (the hook is still set, for consistency, and on web the ds-icon--{size} modifier class stays applied; .ds-icon--inline is declared later and wins). A non-inline overrides.size is written as the --ds-icon-size hook inline, so it outranks the ds-icon--{size} modifier’s declaration of the same hook. React Native has no hook, so there the override is simply ignored while inline. Non-inline icons are display inline-block with vertical-align middle (the Lit host is inline-flex instead; see its note); inline ones sit at vertical-align -0.125em. |
color | color.foreground | #2a2b2f | #ecedee | no | The default is inherit (currentColor): glyphs take the text color, so a Button, Link or Alert colors them for free, and color.foreground is only what inheritance resolves to at the root. The CSS hook therefore defaults to currentColor, not to the token — defaulting it to the token would break that inheritance — and color.foreground is what it resolves to at the root. overrides.color sets an explicit color. On React Native, where there is no currentColor, the order is: the color prop, else overrides.color, else the enclosing Text’s TextStyleContext colour whenever the glyph is nested in a Text (nesting alone is enough; inline governs only the size; any TextStyleContext provider with nested: true counts, so a Heading or Link colours it too), else color.foreground. |
strokeWidth | border.width.focus | 2px | 2px | yes | Stroke thickness of line glyphs (check, dash, chevrons, close, plus, minus, external, search, arrows, calendar, menu), in screen pixels at every size (vector-effect non-scaling-stroke), so glyphs stay legible at xs. Filled glyphs have no stroke: each is one evenodd path whose inner mark is a hole. All three platforms; on React Native through react-native-svg. The binding is locked — it sits on a token that carries an accessibility guarantee — so it is not a member of the overrides type, though the --ds-icon-stroke-width hook still exists for a consumer’s own CSS. A composite that binds its own indicator stroke to the same token (Checkbox’s indicatorStroke) is locked for the same reason and forwards nothing: the two already resolve to one value. react-native-svg honours vector-effect only under react-native-web, so web passes the raw token with vectorEffect="non-scaling-stroke" and native passes the token scaled into the 16-grid at the rendered size, which is what keeps a native stroke from thickening at xl. |
Input
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
background | color.background | #ffffff | #17181b | yes | |
foreground | color.foreground | #2a2b2f | #ecedee | yes | |
placeholder | color.foreground.muted | #56585c | #bcbec1 | yes | |
border | color.border.strong | #939599 | #707176 | yes | |
borderFocus | color.border.focus | #496ded | #99b5ff | yes | |
borderInvalid | color.border.danger | #b51d26 | #ff958d | no | |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | Realised by the composed Text’s danger tone. Locked, so absent from the overrides type, but it keeps its --ds-input-error-text hook on the root, defaulting to var(--color-foreground-danger), as every locked binding does. |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description: Realised by the composed Text’s muted tone. Keeps its --ds-input-description-text hook on the root, defaulting to var(--color-foreground-muted), as for errorText. |
borderWidth | border.width.thin | 1px | 1px | no | |
radius | radius.md | 4px | 4px | no | |
paddingInline | space.md | 12px | 12px | no | |
paddingInline (size=sm) | space.2 | 8px | 8px | no | |
paddingBlock | space.sm | 8px | 8px | no | |
paddingBlock (size=sm) | space.1 | 4px | 4px | no | |
partGap | space.1 | 4px | 4px | no | Vertical gap between label, description, field, and error. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | |
fontSize | font.size.sm | 13px | 13px | no | The field text and the label, so the label follows size; description and error use helperSize. |
fontSize | font.size.md | 16px | 16px | no | The field text and the label, so the label follows size; description and error use helperSize. |
labelWeight | font.weight.medium | 500 | 500 | no | part label: On web and Lit the native label’s weight. On native the label is the system Text with weight="medium", its size following size, and an override is forwarded as that Text’s fontWeight. |
helperSize | font.size.sm | 13px | 13px | no | Description and error text size. Reaches them only through the composed Text’s overrides (fontSize), with fontFamily and lineHeight forwarded the same way; it has no —ds-input-* hook, so page CSS sizes helper text through Text’s own hooks. The description and error Texts are size="sm", matching this default, with the override forwarded on top. fontFamily and lineHeight keep their root hooks as well, for the label and the field. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | |
minTarget | size.target.comfortable | 44px | 44px | yes | |
minTargetSm | size.target.min | 24px | 24px | yes | The field height floor at size sm. Locked like minTarget (an accessibility floor, not an override), so the sm floor is always size.target.min. |
focusRingWidth | border.width.focus | 2px | 2px | yes | Replaces borderWidth when focused (the field’s border IS its focus ring — no outline); when the field is both invalid and focused the danger color stays and only the width changes, so the error is never hidden by focus. Padding shrinks by the difference on both axes — inline and block, since compensating only the inline one would still move the field vertically — and the compensation is clamped at zero, so a borderWidth override wider than this locked width leaves the padding alone rather than eating into it. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | Applied to the whole field group (label, description, field, error), as Button dims the whole control. The group is the root wrapper that also carries partGap; it is not an anatomy part (web: the div with data-ds; Lit: an unnamed wrapper in the shadow root; native: the outer View with testID=“Input”). |
transition | motion.duration.fast | 120ms | 120ms | no | Border color on focus and invalid, with motion.easing.standard. Border width and padding change instantly (no layout animation). Instant under reduced motion. An override changes the duration only; the easing stays motion.easing.standard. Native swaps instantly, so the binding stays in the native overrides type for parity and an override of it has no effect there. |
Landmark
No style bindings.
Link
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
color | color.link | #3553d2 | #99b5ff | yes | |
colorHover | color.link.hover | #243ab3 | #c2d4ff | yes | state hover: Pointer hover only on web and Lit (not :active), as a plain :hover rule with no @media (hover: hover) guard; a sticky hover color after a tap is an accepted, proven pair. On native, which has no hover, the pressed color. |
colorVisited | color.link.visited | #162391 | #dee8ff | yes | Web and Lit only; native has no visited state. |
underlineThickness | border.width.thin | 1px | 1px | no | Text-decoration thickness; the underline is always present at rest. |
underlineOffset | space.1 | 4px | 4px | no | |
externalIconGap | space.1 | 4px | 4px | no | part externalIcon: Gap before the trailing icon, applied as margin-inline-start on Link’s own externalIcon wrapper span (inline text has no Stack to use), never on the Icon inside it. The icon is 1em of the surrounding font size (no token: it scales with the text). On React Native nested Text ignores margins, so the gap is a single literal space and this binding is not applied. An override of it is written only while external is true, the only time the binding is in effect. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes | |
focusRingRadius | radius.sm | 2px | 2px | yes | Applied (as border-radius) only under :focus-visible, so a resting inline anchor is not rounded. |
focusRingOffset | border.width.focus | 2px | 2px | yes | outline-offset of the focus ring on web and Lit, deliberately the same token as its width, so the two are indistinguishable at every theme until one of them is given a token of its own. Not applied on native, where the ring is the platform’s own. |
transition | motion.duration.fast | 120ms | 120ms | no | Color transition on hover, with motion.easing.standard. Only color transitions; the underline follows it through currentColor. |
Listbox
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
surface | color.background | #ffffff | #17181b | yes | |
border | color.border.strong | #939599 | #707176 | no | Only when not embedded; inside a popup the popup owns the border. |
borderInvalid | color.border.danger | #b51d26 | #ff958d | no | Replaces border while invalid, only when not embedded; an embedded list conveys invalid by aria-invalid and the error message alone. |
partGap | space.1 | 4px | 4px | no | Vertical gap between the list and errorMessage. Both sit in the root wrapper, which is not an anatomy part (web: the div with data-ds, whose child is the role=listbox list part, since a listbox may only contain options and groups; Lit: an unnamed wrapper in the shadow root; native: the outer View with testID=“Listbox”). |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | part errorMessage: Realised by the composed Text’s tone danger; declares no hook of its own. |
borderWidth | border.width.thin | 1px | 1px | no | |
radius | radius.md | 4px | 4px | no | |
listPadding | space.1 | 4px | 4px | no | part list. |
optionPaddingBlock | space.sm | 8px | 8px | no | part option. |
optionPaddingInline | space.md | 12px | 12px | no | part option. |
optionGap | layout.gap.normal | 8px | 8px | no | part option: The row’s horizontal gap: between check, icon and the label column. The description sits under the label in that column and takes no gap from it — this much space between a label and its own sub-line would read as two rows — and rows themselves have no gap between them. |
optionRadius | radius.sm | 2px | 2px | no | part option. |
optionColor | color.foreground | #2a2b2f | #ecedee | yes | part option. |
optionDescriptionColor | color.foreground.muted | #56585c | #bcbec1 | yes | part optionDescription. |
optionDescriptionSize | font.size.sm | 13px | 13px | no | part optionDescription. |
optionActiveBackground | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part option, state active: The focused/active option (keyboard or hover). Selection is shown by the check and weight, so active and selected are never confused. |
optionWeight | font.weight.regular | 400 | 400 | no | part option: Unselected options; selected ones use optionSelectedWeight. |
optionSelectedWeight | font.weight.medium | 500 | 500 | no | part option, state selected: Wins over optionWeight on a selected row. |
optionSelectedCheck | color.control.selectedBackground | #3553d2 | #496ded | yes | part optionCheck: The check icon on selected options, in the selected-control fill (3:1 on both surfaces by derivation). Only with multiple, where the slot is present on every row (invisible when unselected) so labels align. Single-select has no check slot and shows selection by optionSelectedWeight alone, never a row fill, since the fill is the active state. |
groupLabelColor | color.foreground.muted | #56585c | #bcbec1 | yes | part groupLabel. |
groupLabelSize | font.size.xs | 11px | 11px | no | part groupLabel. |
groupLabelWeight | font.weight.semibold | 600 | 600 | no | part groupLabel. |
groupLabelPaddingBlock | space.1 | 4px | 4px | no | part groupLabel: Block padding only; the group label’s inline padding is optionPaddingInline, so it lines up with the option labels. |
emptyColor | color.foreground.muted | #56585c | #bcbec1 | yes | part emptyState: Realised by the composed Text’s tone muted (also for copy.loading); declares no hook of its own. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | |
fontSize | font.size.md | 16px | 16px | no | |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | A unitless multiplier, which is what makes fontSize × lineHeight in the row formula a valid length. |
minTarget | size.target.min | 24px | 24px | yes | |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes | Never changes layout. Web and Lit draw it as an outline on the focused list. Native draws it as a row border that is always reserved (transparent unless the row is focused), so native rows are 2 × focusRingWidth taller than the Behavior formula, and maxVisible counts that. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | |
typeaheadReset | motion.duration.loop | 800ms | 800ms | no | part list: How long typed characters accumulate before the typeahead buffer clears; read at runtime from the list’s computed custom property (ms or s parsed to milliseconds). When it cannot be read (no token stylesheet, as in jsdom) the buffer clears immediately, so only single-character typeahead works. React Native has no typeahead and accepts it in its overrides type for parity only. |
Menu
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
surface | color.overlay.surface | #ffffff | #2a2b2f | yes | part popup. |
border | color.border | #d9dbdd | #414246 | no | part popup. |
borderWidth | border.width.thin | 1px | 1px | no | part popup: The popup border, and also the thickness of each separator line. |
shadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | part popup. |
radius | radius.md | 4px | 4px | no | part popup. |
popupPadding | space.1 | 4px | 4px | no | part popup: Inset around the list so item hover backgrounds do not touch the border. |
popupOffset | space.1 | 4px | 4px | no | part popup: Gap between trigger and popup, on the side facing the trigger; the flip check includes it, and a flipped popup keeps the same gap on its new side. On web and Lit the token length is read in px from the popup’s resolved hook when the popup opens and repositions (rem × root font size), and an unresolvable value is 0 — the popup then sits flush against the trigger rather than at a hard-coded fallback; rn uses the resolved theme value. |
typeaheadReset | motion.duration.loop | 800ms | 800ms | no | part popup: How long typed characters accumulate before the typeahead buffer clears; read at runtime from the popup. An unresolvable value clears the buffer after every keypress, degrading typeahead to single characters rather than letting it accumulate forever. |
maxHeight | layout.maxWidth.prose | 572px | 572px | no | part popup: The popup’s block size never exceeds the viewport height minus gutter at each edge (2 × gutter), nor this token; beyond that the list scrolls. The cap is on the popup itself, so its border and popupPadding are inside it (rn: the popup View, with the ScrollView inside). The token is a measure, not a height: it is reused deliberately so a popup is never taller than a comfortable reading measure, which also means retheming layout.maxWidth.prose moves this cap and phoneBreakpoint together. |
gutter | layout.gutter | 24px | 24px | no | part popup: Space kept between the popup and each viewport edge: it caps maxHeight (viewport height − 2 × gutter), bounds the inline shift, and is kept from the block edges too, so a flipped popup anchored to a trigger near the top or bottom is clamped rather than sitting against the edge. Read in px the same way as popupOffset. |
minWidth | space.20 | 80px | 80px | no | part popup: Popup is at least this wide (200px at comfortable density) and at least the trigger width. An override replaces the base; the × 2.5 stays in the rule. The trigger width is only known at runtime, so it is measured when the popup opens and repositions and the popup gets max(<the × 2.5 rule>, <trigger width>px) — on web and Lit the measurement is published as a platform-private custom property (--ds-menu-trigger-width, 0px in anchor mode) that the min-inline-size rule maxes against; it is not an overridable binding and is not in the overrides type. With anchor there is no trigger-width floor, and the anchor’s own width is not one either: the popup falls back to the × 2.5 rule. On rn the width is known only from onLayout, so the popup is held at opacity 0 until the measurement and the layout have both reported, rather than being placed from the floor for one frame and jumping when an end placement learns its real width. |
phoneBreakpoint | layout.maxWidth.prose | 572px | 572px | yes | rn only: a window width at or below this presents as ActionSheet, above it as the anchored popup; the breakpoint is read from the theme token, not per instance. |
itemPaddingBlock | space.sm | 8px | 8px | no | part item: Also pads the groupLabel row so the label lines up with the items. |
itemPaddingInline | space.md | 12px | 12px | no | part item: Also pads the groupLabel row so the label lines up with the items. |
itemGap | layout.gap.normal | 8px | 8px | no | part item. |
itemRadius | radius.sm | 2px | 2px | no | part item. |
itemHover | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part item, state hover: Pointer hover and keyboard focus share this highlight, and on rn so does press (hover tracked with onHoverIn/onHoverOut). |
itemColor | color.foreground | #2a2b2f | #ecedee | yes | part item. |
itemDangerColor | color.foreground.danger | #b51d26 | #ff958d | yes | part item. |
groupLabelColor | color.foreground.muted | #56585c | #bcbec1 | yes | part groupLabel. |
groupLabelSize | font.size.xs | 11px | 11px | no | part groupLabel. |
groupLabelWeight | font.weight.semibold | 600 | 600 | no | part groupLabel. |
shortcutColor | color.foreground.muted | #56585c | #bcbec1 | yes | part itemShortcut. |
shortcutSize | font.size.sm | 13px | 13px | no | part itemShortcut. |
separator | color.border | #d9dbdd | #414246 | no | part separator. |
separatorMargin | space.1 | 4px | 4px | no | part separator. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part popup. |
fontSize | font.size.md | 16px | 16px | no | part popup. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part popup. |
minTarget | size.target.min | 24px | 24px | yes | part item. |
layer | layer.dropdown | 100 | 100 | no | part popup: z-index of the position: fixed popup (web portal, Lit fallback); it has no effect inside the browser top layer (Lit popover=“manual”) or a native Modal window, where it is still written on the popup for parity rather than dropped. |
enter | motion.duration.fast | 120ms | 120ms | no | part popup: Fade and an enterDistance slide from the side facing the trigger after any flip, with motion.easing.standard; instant under reduced motion. A popup placed below starts enterDistance above its resting place and settles down into it; one placed above starts that far below and rises. Popup only: the item highlight changes instantly — itemHover carries state: hover but is exempt from the transition every other hover binding gets, because the highlight also tracks keyboard focus. |
enterDistance | space.1 | 4px | 4px | no | part popup: How far the popup slides in on enter; separate from popupOffset, so overriding the gap does not change the slide. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part item. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part item. |
Meter
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
track | color.background.strong | #ecedee | #414246 | yes | part track. |
fill | color.status.info.icon | #405bb6 | #99b5ff | yes | part fill: The icon step of each status hue is the one guaranteed 3:1 against the page background, which makes it the right non-text fill. |
fill | color.status.success.icon | #00791d | #76d07a | yes | part fill: The icon step of each status hue is the one guaranteed 3:1 against the page background, which makes it the right non-text fill. |
fill | color.status.warning.icon | #825b00 | #e6ac3f | yes | part fill: The icon step of each status hue is the one guaranteed 3:1 against the page background, which makes it the right non-text fill. |
fill | color.status.danger.icon | #b51d26 | #ff958d | yes | part fill: The icon step of each status hue is the one guaranteed 3:1 against the page background, which makes it the right non-text fill. |
trackHeight | space.2 | 8px | 8px | no | part track. |
radius | radius.full | 999px | 999px | no | part track: Rounds the track and the fill ends: the track clips the fill (overflow hidden) and the fill carries the same radius on every platform, so the leading end of a partial fill is rounded too. There is deliberately no separate fill radius — the fill reads the same hook, and an override moves both ends together. |
labelColor | color.foreground | #2a2b2f | #ecedee | yes | part label: Realised by the label Text’s tone default; no hook of its own. |
labelSize | font.size.sm | 13px | 13px | no | part label: Forwarded to the label Text’s fontSize override. |
labelWeight | font.weight.medium | 500 | 500 | no | part label: Forwarded to the label Text’s fontWeight override. |
valueColor | color.foreground.muted | #56585c | #bcbec1 | yes | part valueText: Realised by the value Text’s tone muted; no hook of its own. |
valueSize | font.size.sm | 13px | 13px | no | part valueText: Forwarded to the value Text’s fontSize override. The value text weight is Text’s regular and is not a binding. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part header: Forwarded to both Texts’ fontFamily overrides; never styles them directly. part: header only says it covers both Texts in the header row: the header element itself takes no style and no hook from it. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part header: Forwarded to both Texts’ lineHeight overrides; never styles them directly. part: header only says it covers both Texts in the header row: the header element itself takes no style and no hook from it. |
partGap | space.1 | 4px | 4px | no | part container: Vertical gap between the label row (header) and the track. |
labelGap | space.2 | 8px | 8px | no | part header: Horizontal gap between the label and the value text in the header row. |
transition | motion.duration.base | 200ms | 200ms | no | part fill: Fill inline-size change, with motion.easing.standard; instant under reduced motion. The rule that a width change coming only from layout (first layout, a resize) snaps is a React Native rule: there the fill is measured pixels, so layout moves the animated value. On web and Lit the fill is a percentage, so a resize never changes the declared width and nothing animates — the rule holds there without any code. On native a fraction change arriving in the same commit as a resize is indistinguishable from a pure resize, so it snaps too. |
NumberInput
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
background | color.background | #ffffff | #17181b | yes | |
foreground | color.foreground | #2a2b2f | #ecedee | yes | |
placeholder | color.foreground.muted | #56585c | #bcbec1 | yes | |
border | color.border.strong | #939599 | #707176 | yes | |
borderFocus | color.border.focus | #496ded | #99b5ff | yes | part field: The focus ring is drawn on the bordered field part while the input matches :focus-visible; the input itself has no border. As Input, the ring is the field’s border (width swapped to focusRingWidth, no outline), and both paddingInline and paddingBlock shrink by the width difference so the field does not grow or shift. The two bindings sit on different parts, so the compensation is split: the field publishes the resolved border width as a custom property, subtracts it from its own padding-inline-start (and from padding-inline-end only in the hidden-stepper case, where that padding exists at all), and the input subtracts the inherited delta from its padding-block. |
borderInvalid | color.border.danger | #b51d26 | #ff958d | no | |
borderWidth | border.width.thin | 1px | 1px | no | |
radius | radius.md | 4px | 4px | no | |
paddingInline | space.md | 12px | 12px | no | part field: The field’s inline-start padding; its inline-end padding only when the steppers are hidden, since the stepper Buttons sit flush at the end. |
paddingInline (size=sm) | space.2 | 8px | 8px | no | part field: The field’s inline-start padding; its inline-end padding only when the steppers are hidden, since the stepper Buttons sit flush at the end. |
paddingBlock | space.sm | 8px | 8px | no | |
paddingBlock (size=sm) | space.1 | 4px | 4px | no | |
affixColor | color.foreground.muted | #56585c | #bcbec1 | yes | Prefix and suffix text. |
affixGap | layout.gap.tight | 4px | 4px | no | Between an affix and the value; with steppers shown, the suffix also keeps this gap before the stepper divider. |
stepperGap | layout.gap.none | 0px | 0px | no | The two stepper Buttons sit flush at the end of the field, separated from the input by a hairline. There is no divider between the two Buttons; their icons separate them. |
stepperDivider | color.border | #d9dbdd | #414246 | no | |
stepperDividerWidth | border.width.thin | 1px | 1px | no | Width of the hairline between the input and the steppers (the inline-start border of the element wrapping both stepper Buttons). |
partGap | space.1 | 4px | 4px | no | |
labelWeight | font.weight.medium | 500 | 500 | no | part label. |
helperSize | font.size.sm | 13px | 13px | no | |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description. |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | |
fontSize | font.size.sm | 13px | 13px | no | |
fontSize | font.size.md | 16px | 16px | no | |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | |
minTarget | size.target.comfortable | 44px | 44px | yes | |
minTargetSm | size.target.min | 24px | 24px | yes | The field height floor at size sm. The stepper Buttons are Button size sm at both field sizes and have no target floor of their own: each stretches to the field’s height, so at size sm the applicable floor is this 24px one. That is the right floor for them — a stepper is a pointer convenience whose keyboard and screen-reader equivalent (the arrow keys, the adjustable actions) is always available, which is the WCAG 2.5.8 AA exception the 44px comfortable target does not have to cover. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part field. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | Applied to the label, description, input and affix parts of a disabled field (the description Text through a wrapper NumberInput owns, which carries its data-part, never a rule on the Text — so [data-part="description"] is that wrapper and aria-describedby points at it, while the composition entry names what the wrapper contains); the field frame (border, background) and the errorMessage are not dimmed. Every element that dims also carries aria-disabled="true" — the root group and the input, on React Native the root View and the TextInput beside accessibilityState.disabled, since react-native-web drops accessibilityState — so a contrast checker resolves the dimmed label and value to an inactive component and applies the WCAG 1.4.3 exemption instead of reporting a failure. Not to an element containing the steppers: the stepper Buttons receive disabled (at a bound, or when the field is disabled) and dim once through their own disabled style. |
Popover
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
surface | color.overlay.surface | #ffffff | #2a2b2f | yes | |
border | color.border | #d9dbdd | #414246 | no | |
borderWidth | border.width.thin | 1px | 1px | no | |
shadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | |
radius | radius.md | 4px | 4px | no | |
inset | layout.inset.md | 12px | 12px | no | Padding of the panel surface itself. The body Box gets no inset of its own, so nothing is forwarded to Box. |
partGap | layout.gap.normal | 8px | 8px | no | Between the header row and the body, and between the heading and the close button inside the header row. The header is a row: the heading fills it and the close button sits at its inline end (still at the end when there is no heading), so no padding is reserved for the button. With neither a heading nor a close button (dismissible: false and no heading) the header row is not rendered at all, so this gap applies to nothing and the body meets the panel inset directly. |
offset | space.2 | 8px | 8px | no | Gap between trigger and panel. Web and Lit apply it as a margin on the panel side facing the trigger (the --ds-popover-offset hook), which the flip check reads back from the computed style — a positioning offset, not sibling spacing. Under top/left positioning the margin moves the box only when the facing side is the panel’s top or left edge (data-side bottom or right); when it is its bottom or right edge (data-side top or left), the position math subtracts the read-back value itself, so the gap is the same on all four sides. React Native adds it to the position measured from the trigger. |
arrowSize | space.2 | 8px | 8px | no | part arrow: A square rotated 45°, filled with surface and edged with border at borderWidth on its two outer sides — the two the 45° rotation turns outward: facing edge top → top and left, bottom → bottom and right, left → left and bottom, right → top and right — centered on the panel edge facing the trigger on every platform. “Centered” is a plain 50% of that edge for all eight placements, which is exact for the four plain sides and an approximation at the four corners, where a narrow trigger can leave the arrow pointing past it; clamping it toward the trigger centre would need a measuring pass this component does not make. The square’s center sits on the centerline of the panel’s border on that edge (half borderWidth in from the outer edge), drawn above the panel, so its edged sides meet the panel border and its fill covers the border beneath its base. |
maxWidth | layout.maxWidth.prose | 572px | 572px | no | The panel’s maximum inline size, itself clamped to the viewport: min(maxWidth, 100vw − 2 × gutter), since flip-shift cannot bring a panel wider than the viewport into view. |
gutter | layout.gutter | 24px | 24px | no | Space kept between the panel and each viewport edge when the panel flips or shifts, and the clamp maxWidth reads. It flips only when the opposite side fits within it; otherwise it stays and shifts along the cross axis alone, so the main axis may overflow rather than the panel being squeezed or made to scroll. |
breakpoint | layout.maxWidth.prose | 572px | 572px | yes | React Native only: at a window width <= this token the popover renders as a BottomSheet. The breakpoint is read from the theme token, not per instance. Web and Lit have no narrow presentation. |
layer | layer.dropdown | 100 | 100 | no | The overlay block’s layer: popover is the category (the top layer via the Popover API or showModal()); this binding stays as the hook but has no effect inside the browser top layer or a native Modal, and applies to the position: fixed fallback and the React Native anchor view. |
enter | motion.duration.fast | 120ms | 120ms | no | Fade and an enterDistance slide from the trigger side, with motion.easing.standard; instant under reduced motion. The slide follows the resolved physical side after right-to-left mirroring (data-side on web and Lit, the resolved edge on React Native): the panel starts enterDistance toward the trigger and moves away from it to rest. |
enterDistance | space.1 | 4px | 4px | no | The length of the enter slide from the trigger side. |
exit | motion.duration.fast | 120ms | 120ms | no | Fade out with motion.easing.exit and no slide (the enter slide does not reverse); instant under reduced motion. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes |
ProgressBar
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
track | color.background.strong | #ecedee | #414246 | no | part track. |
fill | color.control.selectedBackground | #3553d2 | #496ded | yes | part fill: Neutral fill. The selected-control color is guaranteed 3:1 against the page. |
fillSuccess | color.status.success.icon | #00791d | #76d07a | yes | part fill. |
fillDanger | color.status.danger.icon | #b51d26 | #ff958d | yes | part fill. |
trackHeight | space.2 | 8px | 8px | no | part track. |
radius | radius.full | 999px | 999px | no | part track: Rounds the track and the fill ends; the track clips the fill (overflow hidden). |
labelColor | color.foreground | #2a2b2f | #ecedee | yes | part label: Realised by the label Text’s tone default; no hook of its own. |
labelSize | font.size.sm | 13px | 13px | no | part label: Forwarded to the label Text’s fontSize override. |
labelWeight | font.weight.medium | 500 | 500 | no | part label: Forwarded to the label Text’s fontWeight override. |
valueColor | color.foreground.muted | #56585c | #bcbec1 | yes | part valueText: Realised by the value Text’s tone muted; no hook of its own. |
valueSize | font.size.sm | 13px | 13px | no | part valueText: Forwarded to the value Text’s fontSize override. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part header: Forwarded to both Texts’ fontFamily overrides; never styles them directly. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part header: Forwarded to both Texts’ lineHeight overrides; never styles them directly. |
partGap | space.1 | 4px | 4px | no | part container: Vertical gap between the label row (header) and the track. |
labelGap | space.2 | 8px | 8px | no | part header: Horizontal gap between the label and the value text in the header row. The label’s wrapper shrinks (flexShrink 1, min inline size 0) so a long label wraps inside the row instead of pushing the value text out of it — layout, with no token of its own, as Meter does. |
transition | motion.duration.base | 200ms | 200ms | no | part fill: Fill inline-size change with motion.easing.standard; instant under reduced motion. An override changes the duration only; the easing is read from the token and has no hook (the sweep’s easing is sweepEasing). |
indeterminateLoop | motion.duration.loop | 800ms | 800ms | no | part fill: The indeterminate sweep: a fill one third of the track width (the one-third ratio is geometry, not a token; a literal is allowed for it) travelling from the inline start to the inline end (right to left in RTL) and repeating, starting and ending wholly outside the track — from -(sweep width) to +(track width) on the inline axis, mirrored to +(sweep width)…-(track width) in RTL. Under reduced motion there is no sweep: the fill is drawn static and full-width at indeterminateReducedOpacity, keeping its tone color. |
indeterminateReducedOpacity | opacity.disabled | 0.5 | 0.5 | no | part fill: Opacity of the static full-width fill that replaces the sweep under reduced motion, on every platform. A binding rather than a literal so a theme can make the reduced-motion form more or less prominent. |
sweepEasing | motion.easing.standard | cubic-bezier(0.2, 0, 0, 1) | cubic-bezier(0.2, 0, 0, 1) | no | part fill: Easing of each indeterminate sweep on every platform. The fill is off the track at both ends of the loop, so the eased restart has no visible seam. |
RadioGroup
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
controlBackground | color.control.background | #ffffff | #2a2b2f | yes | part radio. |
controlBorder | color.control.border | #939599 | #707176 | yes | part radio. |
controlBorderWidth | border.width.thin | 1px | 1px | no | part radio. |
controlSelectedBackground | color.control.selectedBackground | #3553d2 | #496ded | yes | part radio: Selected border color; the fill stays controlBackground and the dot is drawn inside. |
indicator | color.control.selectedBackground | #3553d2 | #496ded | yes | part radioIndicator: The centre dot, controlSize minus 2 × indicatorInset in diameter, centred, and always a circle (it does not follow controlRadius). Its size does not change on focus: the thicker focus border eats into the inset, and the dot still fits whenever indicatorInset ≥ focusRingWidth (true at the default tokens), so it never shrinks. One rule on every platform: the dot is a real node, so it carries the part name everywhere. Web and Lit draw it as a span the RadioGroup owns inside the option’s label, positioned over the appearance:none input and pointer-events: none, with data-part="radioIndicator" (and part on Lit) — not a pseudo-element of the input, because Firefox does not render ::before or ::after on form controls and the dot would simply be missing there, leaving selection conveyed by border colour alone; on native it is a View inside the drawn control carrying testID=“RadioGroup.radioIndicator”. |
indicatorInset | space.1 | 4px | 4px | no | part radioIndicator: Gap between the control’s outer edge and the dot on each side; sets the dot diameter (controlSize − 2 × indicatorInset). On React Native the border is drawn inside the box and the dot does not shrink, so an inset below focusRingWidth clips the dot while focused: focusRingWidth is the floor this binding should be overridden above. |
controlBorderInvalid | color.border.danger | #b51d26 | #ff958d | no | part radio: Painted on every radio while the group is invalid, selected ones included — it sits above controlSelectedBackground in the border-colour order and below nothing: focus does not replace it. Border colour precedence is invalid, then selected, then rest, the same order as Checkbox, and a focused invalid radio keeps the danger colour while only the width changes to focusRingWidth. |
controlSize | space.5 | 20px | 20px | no | part radio: Outer size of the drawn circle, border included (border-box); it stays this size when the focus border thickens. |
controlRadius | radius.full | 999px | 999px | no | part radio: Corner radius of the drawn control only; the dot stays a circle. |
optionPaddingBlock | space.1 | 4px | 4px | no | Block padding of each option row (the row wrapper holding radio, radioLabel and radioDescription; not an anatomy part, so the binding has no part and the row carries no data-part/testID). The row still meets minTarget. |
optionTextGap | space.1 | 4px | 4px | no | part radioDescription: Vertical gap between an option’s radioLabel and its radioDescription. |
optionGap | space.2 | 8px | 8px | no | Horizontal gap between radio and its label. |
listGap | space.2 | 8px | 8px | no | Gap between options (vertical or horizontal). |
partGap | space.1 | 4px | 4px | no | Vertical gap between legend, description, list, and error. |
legendColor | color.foreground | #2a2b2f | #ecedee | yes | part legend. |
legendSize | font.size.md | 16px | 16px | no | part legend. |
legendWeight | font.weight.medium | 500 | 500 | no | part legend. |
labelColor | color.foreground | #2a2b2f | #ecedee | yes | part radioLabel. |
labelSize | font.size.md | 16px | 16px | no | part radioLabel. |
labelWeight | font.weight.regular | 400 | 400 | no | part radioLabel. |
helperSize | font.size.sm | 13px | 13px | no | Size of description, radioDescription and errorMessage; reaches the composed Text only through its fontSize override. Forwarded-only, so it has no —ds-radio-group-* hook on web or Lit; consumers change it through overrides. |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description: Realised by the composed Text’s tone muted (description and radioDescription); no —ds-radio-group-* hook. |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | part errorMessage: Realised by the composed Text’s tone danger; no —ds-radio-group-* hook. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | Applies to legend and radioLabel (their own rule on web/Lit, keeping its hook; a Text override on native) and is forwarded to the description, radioDescription and errorMessage Texts. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | As fontFamily: legend and radioLabel directly, forwarded to every composed Text. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part radio: On focus-visible the radio’s border becomes focusRingWidth in this color, replacing controlBorderWidth/controlBorder (web, Lit and native alike); the option row is not outlined. It does not replace controlBorderInvalid: in an invalid group a focused radio keeps the danger colour and takes only the focus width, as Checkbox does. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part radio. |
minTarget | size.target.comfortable | 44px | 44px | yes | Minimum height of each option row; the whole row is the hit area, radioDescription included — a click anywhere in the row, the description among it, selects that option. It is a minimum, not the height: at the default tokens a single-line row is exactly this tall and optionPaddingBlock has no visible effect, and an override of that padding can only grow the row past this floor, never shrink it below. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | Dims option rows only. A disabled option in an enabled group dims its own row; a disabled group dims every option row once (not stacked with the option’s own opacity) and leaves legend, description and errorMessage at full opacity. |
transition | motion.duration.fast | 120ms | 120ms | no | part radio: The selected border color and the dot’s opacity (fading in and out), with motion.easing.standard, on every platform. Invalid and focus border changes switch instantly — one-directionally, which is all CSS can express from a single border-color transition: entering those states is instant, leaving them fades back over transition, and that asymmetry is accepted rather than paid for with an extra element. Instant under reduced motion. |
Search
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
background | color.control.background | #ffffff | #2a2b2f | yes | |
foreground | color.foreground | #2a2b2f | #ecedee | yes | |
placeholder | color.foreground.muted | #56585c | #bcbec1 | yes | |
iconColor | color.foreground.muted | #56585c | #bcbec1 | yes | part icon: The leading search glyph only, forwarded to that Icon’s own color override. The clear and submit glyphs keep their ghost Buttons’ own colors; nothing is forwarded into a Button. The glyph’s size is not a binding on purpose: it is derived from size (sm at md, md at lg) so the glyph always tracks the text, and is not independently themeable. |
border | color.border.strong | #939599 | #707176 | yes | |
borderFocus | color.border.focus | #496ded | #99b5ff | yes | The field wrapper’s border while the input itself has focus (web and Lit: :has(input:focus-visible), not :focus-within, so a focused clear or submit Button shows only its own ring). The input draws no outline of its own. |
borderWidth | border.width.thin | 1px | 1px | no | |
radius | radius.full | 999px | 999px | no | A pill: the one place the system uses the full radius on a field, so search reads as search. |
paddingInline | space.md | 12px | 12px | no | |
paddingBlock | space.sm | 8px | 8px | no | |
paddingBlock (size=lg) | space.md | 12px | 12px | no | |
affixGap | layout.gap.tight | 4px | 4px | no | Between the glyph, the input and the buttons. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | |
fontSize | font.size.md | 16px | 16px | no | |
fontSize | font.size.lg | 19px | 19px | no | |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | |
suggestionsOffset | space.1 | 4px | 4px | no | part suggestions: Gap between the field and the popup. It is folded into the fixed popup’s own position calculation — exactly the mechanism ds-combobox uses — not applied as a margin afterwards, which would position the popup and then shift it again. |
popupSurface | color.overlay.surface | #ffffff | #2a2b2f | no | |
popupBorder | color.border | #d9dbdd | #414246 | no | |
popupBorderWidth | border.width.thin | 1px | 1px | no | |
popupRadius | radius.md | 4px | 4px | no | |
popupShadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | |
layer | layer.dropdown | 100 | 100 | no | Stacking of the suggestions popup on web and Lit, placed below the field and flipped above it when the viewport has no room, as Combobox. Unused on native, where the list is inline: it stays in the native overridable set for parity and an override of it there is accepted and does nothing. |
partGap | space.1 | 4px | 4px | no | Between the visible label and the field. |
labelWeight | font.weight.medium | 500 | 500 | no | part label: Forwarded to the label Text’s own fontWeight override; the label’s size follows fontSize, forwarded as its fontSize override, so a shown label tracks size as Input’s does. Both forwards always carry the binding’s token (font.size.{size} when not overridden). Forwarded bindings (this, fontSize to the label, iconColor) reach the child only through its overrides; they have no —ds-search-* CSS hook, and none is defined on :host either — an unread custom property would look like a working one. They are reachable through the overrides prop and nowhere else, which is the exception to the “every binding is also a custom property” rule. |
minTarget | size.target.comfortable | 44px | 44px | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes | Replaces borderWidth on the field wrapper while the input is focused (the field border, in borderFocus, is the focus ring, as Input and Combobox); paddingInline and paddingBlock shrink by the difference so the content does not shift. No separate outline. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | Applied to the label, glyph and input of a disabled Search, not to an element containing the Buttons, which receive disabled and dim once through their own style (as NumberInput’s steppers). The field frame (border, background) is not dimmed, so the dimmed text sits on an undimmed control background and falls below the a11y.contrast figures: those pairs describe the enabled state, and the disabled state relies on the WCAG 1.4.3 exemption for inactive components, made machine-visible by aria-disabled="true" on the dimmed root as well as the input (on React Native the root View beside accessibilityState.disabled, since react-native-web drops the object form). |
SegmentedControl
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
groupBackground | color.background.strong | #ecedee | #414246 | yes | part group. |
groupPadding | space.1 | 4px | 4px | no | part group. |
groupRadius | radius.md | 4px | 4px | no | part group. |
segmentColor | color.foreground.muted | #56585c | #bcbec1 | yes | part segment. |
segmentSelectedColor | color.foreground.strong | #000000 | #ffffff | yes | part segment. |
segmentSelectedBackground | color.background | #ffffff | #17181b | yes | part indicator: The raised pill under the selected segment — drawn on the indicator part, never on the segment itself. |
segmentShadow | shadow.raised | 0px 1px 3px 0px #0000001a | 0px 1px 3px 0px #00000059 | no | part indicator: The pill’s shadow. The one binding here whose token resolves to an object rather than a scalar: on React Native it expands into the shadow style keys (shadowColor, shadowOffset, shadowRadius, elevation), which are spread into the pill’s style, and an override is resolved the same way. |
segmentRadius | radius.sm | 2px | 2px | no | part indicator: The pill’s corners; the segment’s focus ring uses the same radius. |
segmentPaddingInline | space.md | 12px | 12px | no | part segment. |
segmentPaddingBlock | space.1 | 4px | 4px | no | part segment: Vertical padding at size md. |
segmentGap | layout.gap.tight | 4px | 4px | no | part segment: Between icon and label inside a segment; no effect with iconOnly, where a segment has one child. |
segmentSpacing | space.0 | 0px | 0px | no | part group: Between adjacent segments, applied as the group’s gap (never a segment margin): none — the pill slides under abutting segments. |
selectedWeight | font.weight.semibold | 600 | 600 | no | part segment: The selected segment’s label; unselected use fontWeight. |
paddingBlockSm | space.1 | 4px | 4px | no | part segment: Vertical padding at size sm; md uses segmentPaddingBlock. The two are the same token on purpose, so size changes the height only through fontSize and its line box — and on React Native, where every segment reaches size.target.comfortable, not at all: there size changes the type and the intrinsic width only, which is the intended outcome. They stay two independent hooks all the same (an sm rule reads its own --ds-segmented-control-padding-block-sm), so overriding one never moves the other. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part segment. |
fontSize | font.size.sm | 13px | 13px | no | part segment. |
fontSize | font.size.md | 16px | 16px | no | part segment. |
fontWeight | font.weight.medium | 500 | 500 | no | part segment. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part segment. |
minTarget | size.target.min | 24px | 24px | yes | part segment: Each segment’s minimum, on both axes (min-inline-size and min-block-size), so a one-character label still makes a square-ish target. The one binding whose token resolves per platform: size.target.min on web and Lit, size.target.comfortable on React Native, which is touch and where every segment therefore reaches 44px. Web and Lit keep the smaller floor because no CSS query tells a touch screen from a hybrid laptop and guessing would shrink or grow the control for the wrong people. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part segment. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part segment. |
transition | motion.duration.fast | 120ms | 120ms | no | part indicator: Pill movement; instant under reduced motion. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | part segment: The segment part only: a checked-but-disabled option keeps a full-opacity pill under a faded label, since the pill is the indicator part and the selection it marks is still true. |
Select
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
triggerBackground | color.background | #ffffff | #17181b | yes | part trigger. |
triggerBorder | color.border.strong | #939599 | #707176 | yes | part trigger. |
triggerBorderFocus | color.border.focus | #496ded | #99b5ff | yes | part trigger: The border color while the trigger is keyboard-focused (:focus-visible). When the field is both invalid and focused the danger color stays and only the width changes (focusRingWidth), as Input, so the error is never hidden by focus. |
triggerBorderInvalid | color.border.danger | #b51d26 | #ff958d | no | part trigger. |
triggerBorderWidth | border.width.thin | 1px | 1px | no | part trigger. |
triggerRadius | radius.md | 4px | 4px | no | part trigger. |
triggerPaddingInline | space.md | 12px | 12px | no | part trigger. |
triggerPaddingBlock | space.sm | 8px | 8px | no | part trigger. |
triggerPaddingBlock (size=sm) | space.1 | 4px | 4px | no | part trigger. |
triggerGap | layout.gap.normal | 8px | 8px | no | part trigger: Between value and chevron. |
valueColor | color.foreground | #2a2b2f | #ecedee | yes | part value: Realised by the composed value Text’s default tone while an option is selected. |
placeholderColor | color.foreground.muted | #56585c | #bcbec1 | yes | part value: The value Text while it shows the placeholder, realised by its muted tone. |
chevron | color.foreground.muted | #56585c | #bcbec1 | yes | part chevron: Forwarded as the composed Icon’s overrides.color (never its color prop, which would win over the override), always carrying this token; like the tone-realised colors it has no —ds-select-* hook. |
chevronReserve | font.size.sm | 13px | 13px | no | part trigger: In effect wherever a native |
partGap | space.1 | 4px | 4px | no | Between label, description, trigger and error, on the field group (the root wrapper, not an anatomy part — as Input). |
labelWeight | font.weight.medium | 500 | 500 | no | part label: Reaches the label only through the composed Text’s overrides (fontWeight); no —ds-select-* hook, since a hook could not reach the child without restyling it. |
helperSize | font.size.sm | 13px | 13px | no | Description and error text size, forwarded into the composed Text’s overrides (fontSize); no —ds-select-* hook, as for labelWeight. |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description: Realised by the composed Text’s muted tone. |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | part errorMessage: Realised by the composed Text’s danger tone. |
popupSurface | color.overlay.surface | #ffffff | #2a2b2f | no | part popup. |
popupBorder | color.border | #d9dbdd | #414246 | no | part popup. |
popupBorderWidth | border.width.thin | 1px | 1px | no | part popup. |
popupShadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | part popup. |
popupRadius | radius.md | 4px | 4px | no | part popup. |
popupOffset | space.1 | 4px | 4px | no | part popup: The gap between trigger and popup, on the side the popup opens (below, or above when flipped). Applied as the popup’s block margin on both sides (a position: fixed popup has no parent gap to use); only the side facing the trigger shows, and the flip test reads the resolved margin. |
layer | layer.dropdown | 100 | 100 | no | part popup. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | Label, value and helper Text, and forwarded into the composed Listbox’s own fontFamily override. |
fontSize | font.size.sm | 13px | 13px | no | The value text and the label, so both follow size; description and error use helperSize. Not forwarded to the Listbox: the popup does not follow size, so options keep Listbox’s own fontSize. |
fontSize | font.size.md | 16px | 16px | no | The value text and the label, so both follow size; description and error use helperSize. Not forwarded to the Listbox: the popup does not follow size, so options keep Listbox’s own fontSize. |
fontWeight | font.weight.regular | 400 | 400 | no | part value: Weight of the trigger’s value text only. Not forwarded to Listbox’s optionWeight: like fontSize, the trigger’s value type does not reach the popup, so options keep Listbox’s own weights (optionWeight, optionSelectedWeight). It exists as an override seam for composites that set their own header type — DatePicker forwards its monthTitleWeight into it. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | Label, value and helper Text, and forwarded into the composed Listbox’s own lineHeight override. |
minTarget | size.target.comfortable | 44px | 44px | yes | part trigger. |
minTargetSm | size.target.min | 24px | 24px | yes | part trigger: The trigger height floor at size sm. The popup is unchanged. This is the documented exception to the target-44px requirement, which applies at md: sm exists for toolbar and calendar-header pickers, where a 44px control would not fit, and it still meets the 24px floor. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part trigger: Replaces triggerBorderWidth while the trigger is keyboard-focused (the border is the focus ring — no outline). Block and inline padding shrink by (focusRingWidth − the resolved triggerBorderWidth) so the trigger does not shift — calc(padding - (focusRingWidth - triggerBorderWidth)), the same unclamped rule Input uses, so the two fields agree and no zero literal is needed. On React Native, where Pressable focus cannot tell keyboard from touch, the width changes on any focus — and on a device it never changes at all, since Pressable receives no focus events outside react-native-web, so there is no focus ring there. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | Applied to the whole field group (label, description, trigger, error), as Input. |
enter | motion.duration.fast | 120ms | 120ms | no | part popup: Popup opacity fade with motion.easing.standard — no slide, no chevron rotation, no trigger border transition. Instant under reduced motion. Opening only: closing is instant on every platform — on web because the Popover API top layer has no exit transition without transitioning display, and elsewhere so that a popup, a sheet and a Modal all disappear the same way. |
SidePanel
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
scrim | color.overlay.scrim | #00000066 | #00000099 | no | part scrim. |
surface | color.overlay.surface | #ffffff | #2a2b2f | yes | part surface. |
shadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | Overlay mode only; the persistent sidebar has a border instead. |
border | color.border | #d9dbdd | #414246 | no | The inner edge of a persistent sidebar — the edge facing the content: inline-end for side: start, inline-start for side: end. |
borderWidth | border.width.thin | 1px | 1px | no | |
width | layout.maxWidth.prose | 572px | 572px | no | Default panel width on wide screens; narrow is space.20 × 3 (a link list), wide is layout.maxWidth.content. Below the prose breakpoint the panel is the viewport minus edgeGutter; on web and Lit that is 100% of the fixed containing block, written verbatim rather than as 100vw — the two differ by a classic scrollbar, and 100% is the one that keeps the panel inside the viewport. The cap applies to all three width values, so a narrow panel on a very small screen still leaves the gutter. |
widthNarrow | space.20 | 80px | 80px | no | space.20 × 3. The hook holds the space.20 unit and the rule multiplies it by 3, so an override replaces the unit, not the final width. |
widthWide | layout.maxWidth.content | 960px | 960px | no | |
edgeGutter | space.12 | 48px | 48px | no | The strip of scrim left visible beside a phone-width panel, so the page is still seen and tappable to close. |
inset | layout.inset.lg | 16px | 16px | no | part body: Pads the inline edges of every part: forwarded as the body Box’s paddingInline (the Box’s block padding stays zero), and read directly as the header’s and footer’s inline padding. The block edges are padded once, on the column that holds the parts (padding-block: inset), never per part, so nothing doubles with partGap; in overlay mode the safe-area inset adds to that column’s block-start and block-end padding. On web and Lit the forward travels through the Box’s --ds-box-padding-inline set to --ds-side-panel-inset in SidePanel’s stylesheet, and overrides.paddingInline is passed only when the caller overrode inset; on rn the resolved value is always passed in overrides. No hook beyond --ds-side-panel-inset. |
headerGap | layout.gap.normal | 8px | 8px | no | part header: Between title and close button. |
headingGap | space.0 | 0px | 0px | no | part heading: Forwarded to the Heading’s marginBlockEnd, turning its own margin off (as Table and DataGrid do) so the title centers against the close button; headerGap and partGap own the spacing. It travels the way inset does: on web and Lit the heading wrapper’s rule sets the Heading’s own --ds-heading-margin-block-end from --ds-side-panel-heading-gap, so the default and any override arrive by the same route and no conditional prop is needed; on rn the resolved value is passed in overrides. |
partGap | layout.gap.loose | 16px | 16px | no | part focusScope: The only space between header, body and footer: the gap of the column that holds them, which also carries padding-block from inset. On web and Lit that column is the focusScope part — FocusScope writes its own data-part="scope", so it is an overlay-owned element directly inside FocusScope (and inside the Landmark when non-modal) carrying data-part="focusScope". In modal mode the surface sits between the testID="SidePanel.focusScope" — it is the View that carries this binding. Persistent mode renders no FocusScope at all, and there the sidebar root View is that column and carries the same testID, so the part is addressable in both modes. |
footerGap | layout.gap.tight | 4px | 4px | no | part footer. |
layer | layer.sheet | 200 | 200 | no | Has no effect inside the browser top layer (the modal |
enter | motion.duration.base | 200ms | 200ms | no | Slide in from the edge with the scrim fading over the same duration and easing; motion.easing.standard; instant under reduced motion. The easing is read from the token, not a binding, and is not overridable. |
exit | motion.duration.fast | 120ms | 120ms | no | Slide out, and the scrim fades out, with motion.easing.exit (read from the token, not overridable). A swipe dismiss (React Native) has no momentum or decay physics: the surface holds the released offset until the consumer’s next render, and closing plays this same timing transition from that offset; if open is still true on that render it springs back. open is optional here, and an uncontrolled panel flips its own state in the same batch as the release, so the held offset is always followed by the exit and the spring-back branch belongs to controlled panels alone. A swipe released below the dismiss threshold, or a dismiss the consumer did not honor, springs back over this duration with motion.easing.standard (a timing animation, not a spring), instant under reduced motion; an interrupted enter animation is finished by the spring-back. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes |
Slider
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
track | color.background.strong | #ecedee | #414246 | no | part track. |
fill | color.control.selectedBackground | #3553d2 | #496ded | yes | part fill. |
trackHeight | space.1 | 4px | 4px | no | part track. |
trackRadius | radius.full | 999px | 999px | no | part track. |
thumb | color.control.background | #ffffff | #2a2b2f | no | part thumb. |
thumbBorder | color.control.selectedBackground | #3553d2 | #496ded | yes | part thumb. |
thumbBorderWidth | border.width.focus | 2px | 2px | yes | part thumb. |
thumbSize | space.5 | 20px | 20px | no | part thumb. |
thumbShadow | shadow.raised | 0px 1px 3px 0px #0000001a | 0px 1px 3px 0px #00000059 | no | part thumb. |
thumbActiveScale | opacity.disabled | 0.5 | 0.5 | no | part thumb: Not a scale — the pressed thumb shows a circular halo of the fill color at this opacity, centred on the knob, with diameter thumbSize + 2 × haloSpread. No literal scale factor exists. Focus alone never shows it. On React Native, where there is no hover and no press state distinct from a drag, the halo shows while a gesture owns that thumb (including a track press that grabbed it). |
haloSpread | space.2 | 8px | 8px | no | part thumb: How far the pressed-thumb halo extends beyond the knob on each side. |
mark | color.border.strong | #939599 | #707176 | no | part tickMarks: Mark dot colour. |
markSize | space.2 | 8px | 8px | no | part tickMarks: Mark dot diameter. Deliberately larger than trackHeight so a mark reads as a tick standing proud of the rail rather than a bump on it. |
markLabelColor | color.foreground.muted | #56585c | #bcbec1 | yes | part tickMarks: Realised by the mark label Text’s tone muted. Locked, it keeps its --ds-slider-mark-label-color hook on web and Lit, as every locked binding does; the tick marks pass it to the mark label Text through the colour that Text resolves (its own --ds-text-color, or a re-scoped --color-foreground-muted), never by restyling the Text. |
markLabelSize | font.size.xs | 11px | 11px | no | part tickMarks: Forwarded to each mark label Text’s fontSize override (fontFamily is forwarded too); never styles the Text directly. |
markLabelGap | space.1 | 4px | 4px | no | part tickMarks: Gap between the bottom of the track area (after trackPaddingBlock) and the mark label row; partGap does not apply between them (on React Native, a column wraps the track area and the label row with this gap). The slider grows by the label line (markLabelSize × font.lineHeight.normal) only when some mark has a label. |
valueColor | color.foreground | #2a2b2f | #ecedee | yes | part valueText: Realised by the value Text’s tone default; no hook of its own. |
valueSize | font.size.sm | 13px | 13px | no | part valueText: Forwarded to the value Text’s fontSize override, and to the bubble’s Text the same way. |
bubbleSurface | color.inverse.surface | #17181b | #ecedee | yes | part bubble: The hover/drag value bubble uses the inverse surface, like Tooltip. |
bubbleText | color.inverse.foreground | #f6f7f8 | #17181b | yes | part bubble: The bubble’s value is a Text (size sm, tone default) with valueSize forwarded. Its colour is not forwarded (Text color is locked): web and Lit re-scope --color-foreground on the bubble, React Native provides TextForegroundContext, as Text’s color binding describes. |
bubblePaddingBlock | space.1 | 4px | 4px | no | part bubble. |
bubblePaddingInline | space.2 | 8px | 8px | no | part bubble. |
bubbleOffset | space.1 | 4px | 4px | no | part bubble: Gap between the bubble’s bottom edge and the top of the active thumb’s hit area (minTarget); the bubble is centred horizontally on the thumb. |
bubbleRadius | radius.sm | 2px | 2px | no | part bubble: The bubble’s corners, the same shape as a Tooltip. The bubble is its own part (not the valueText Text): an inverse-surface pill above the active thumb. |
labelWeight | font.weight.medium | 500 | 500 | no | part label: Forwarded to the label Text’s fontWeight override. |
partGap | space.1 | 4px | 4px | no | Vertical gap on the slider root between the label row, the track area, and the description/error message. |
labelGap | space.2 | 8px | 8px | no | part label: Horizontal gap between the label and the value text in the label row. |
trackPaddingBlock | space.3 | 12px | 12px | no | part track: Vertical space around the track so the thumb and its halo have room and the touch target reaches the comfortable size. It pads the track area, an unparted wrapper around the track and thumbs that is also the pointer hit area, never the coloured rail itself (padding the rail would thicken it). |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part label: Forwarded to every composed Text’s fontFamily override (label, value, bubble, mark labels, description, error); never styles them directly. |
fontSize | font.size.md | 16px | 16px | no | part label: Forwarded to the label Text’s fontSize override. |
helperSize | font.size.sm | 13px | 13px | no | part description: Forwarded to the description and error message Texts’ fontSize overrides. |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description: Realised by the composed Text’s muted tone; no hook of its own. |
errorText | color.foreground.danger | #b51d26 | #ff958d | yes | part errorMessage: Realised by the composed Text’s danger tone; no hook of its own, since Text’s colour is locked and a hook could not reach the child without restyling it (the same as Input). Locked means absent from the overridable type, not accepted and ignored. |
minTarget | size.target.comfortable | 44px | 44px | yes | part thumb: The thumb’s hit area, centred on the knob. It is taller than the track area (trackHeight + 2 × trackPaddingBlock) and is allowed to overflow it above and below rather than growing the row: nothing clips it, and the label row and mark label row paint above the thumbs and take any press that lands on them. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part thumb: A circular ring around the visible knob (not the minTarget hit area), shown only while the thumb has keyboard focus. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part thumb: Ring thickness, drawn outside the knob’s border and offset from it by the same width. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | Applied to the slider root (label row, track area, marks and messages) while disabled. The element that dims also carries aria-disabled="true" — the root as well as each thumb (on Lit the shadow-root wrapper, on React Native the root View beside accessibilityState.disabled) — so a contrast checker resolves the dimmed label and value text to an inactive component and applies the WCAG 1.4.3 exemption instead of reporting a failure. This holds for every component whose disabledOpacity dims text. |
transition | motion.duration.fast | 120ms | 120ms | no | Halo (part thumb) and bubble (part bubble) appearance; the thumb itself follows the pointer with no transition. |
Splitter
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
separatorSize | space.1 | 4px | 4px | no | part separator: The visible line. The grab area is wider: handleSize centered on it. |
separatorColor | color.border | #d9dbdd | #414246 | no | part separator. |
separatorHover | color.border.strong | #939599 | #707176 | yes | part separator, state hover: Pointer hover; unused on native. |
separatorActive | color.control.selectedBackground | #3553d2 | #496ded | yes | part separator, state dragging: While dragging or focused (:focus-visible). The dragging state is a ds-splitter--dragging root class on web and a data-dragging attribute on the Lit separator. On native the separator has no focus events, so it applies only while dragging. |
handleSize | space.3 | 12px | 12px | no | part handle: Pointer grab area, centered on the separator and overlapping both panes so the panes’ content keeps the full width. The effective hit area is max(handleSize, minTarget), so the locked floor cannot be overridden away. handle is a pseudo-element part on web and Lit — the separator’s ::before — so nothing there matches a part selector for it; on native it is the absolutely positioned child View, which carries the testID. |
grip | color.border.strong | #939599 | #707176 | yes | A short centered grip mark on the separator, so the divider reads as draggable: a rounded bar of gripLength along the separator and separatorSize across it. The grip is a mark inside the separator and not an anatomy part of its own — a pseudo-element on web and Lit, a child View on native — which is why it, gripLength and gripRadius name no part. |
gripLength | space.6 | 24px | 24px | no | |
gripRadius | radius.full | 999px | 999px | no | Rounds the grip bar’s ends. |
collapseButtonOffset | space.2 | 8px | 8px | no | part collapseButton: Distance of the collapse Button from the separator’s start edge along the separator (top of a vertical separator, inline-start of a horizontal one); the button is centered across the separator and overlaps both panes. Expanded, that centering is the anchor: the button’s cross-axis center sits on the separator line. While collapsed the primary pane has no size, so the button aligns to the secondary pane’s start edge instead of hanging outside the container — its cross-axis start edge sits at the separator’s end edge, with no centering, so no part of it falls outside. |
paneMinTarget | size.target.comfortable | 44px | 44px | yes | A pane never shrinks below this on the drag axis before collapsing, so its scrollbar and content stay usable: a CSS minmax() floor on both grid tracks beneath the percent clamp (a very narrow container can therefore show the pane wider than its percent). Dropped for the primary track while collapsed so collapse reaches zero. On native it is minWidth (or minHeight when vertical) on both panes, dropped for the primary pane while collapsed. A React Native floor is hard, not a preference: the primary pane takes flexShrink: 0, so where the container is too narrow for both floors plus the separator the secondary shrinks to its own floor and then the splitter overflows, rather than either pane dropping below its floor as the web grid allows. |
minTarget | size.target.min | 24px | 24px | yes | Separator hit area along the drag axis. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | The separator’s ring on web and Lit. The native separator is a plain View with a PanResponder and has no focus events, so on React Native this reaches only the composed collapse Button — as separatorHover has no native expression either. |
focusRingWidth | border.width.focus | 2px | 2px | yes | Width of that ring, on the same elements. |
transition | motion.duration.fast | 120ms | 120ms | no | Collapse and restore, and the separator color; dragging itself has no transition. The pane size is a custom property driving a grid track, which only animates where @property registration is reliable — where it is not, collapse and restore are instant and only the separator colour transitions. Only a change of the collapsed state animates; key steps and drags resize instantly. Web and Lit register the same property the same way (--ds-splitter-primary-size, syntax <percentage>, not inherited): Lit calls CSS.registerProperty at module load in try/catch because @property does not apply inside shadow roots. The animating state gets a named hook of its own, as the dragging state does: a ds-splitter--animate root class on web and a data-animating attribute on the Lit container, set when the collapsed state changes and cleared by the next size change or the next pointerdown — a collapse that is never followed by a resize leaves it set, which is what lets a second toggle animate too. On native, collapse and restore animate an Animated.Value interpolated to the primary pane’s flexBasis percentage (no native driver, since react-native-web has none) and the separator colour switches instantly. |
Stack
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
gap | layout.gap.none | 0px | 0px | no | gap: none resolves layout.gap.none, a real token that is zero, and makes overrides.gap a no-op, per the presence rule; on web and Lit the none rule reads var(--layout-gap-none) directly rather than the --ds-stack-gap hook, so consumer CSS on the hook is ignored there too. It is read as a token on every platform — none of them writes a bare 0. |
gap | layout.gap.tight | 4px | 4px | no | gap: none resolves layout.gap.none, a real token that is zero, and makes overrides.gap a no-op, per the presence rule; on web and Lit the none rule reads var(--layout-gap-none) directly rather than the --ds-stack-gap hook, so consumer CSS on the hook is ignored there too. It is read as a token on every platform — none of them writes a bare 0. |
gap | layout.gap.normal | 8px | 8px | no | gap: none resolves layout.gap.none, a real token that is zero, and makes overrides.gap a no-op, per the presence rule; on web and Lit the none rule reads var(--layout-gap-none) directly rather than the --ds-stack-gap hook, so consumer CSS on the hook is ignored there too. It is read as a token on every platform — none of them writes a bare 0. |
gap | layout.gap.loose | 16px | 16px | no | gap: none resolves layout.gap.none, a real token that is zero, and makes overrides.gap a no-op, per the presence rule; on web and Lit the none rule reads var(--layout-gap-none) directly rather than the --ds-stack-gap hook, so consumer CSS on the hook is ignored there too. It is read as a token on every platform — none of them writes a bare 0. |
gap | layout.gap.section | 32px | 32px | no | gap: none resolves layout.gap.none, a real token that is zero, and makes overrides.gap a no-op, per the presence rule; on web and Lit the none rule reads var(--layout-gap-none) directly rather than the --ds-stack-gap hook, so consumer CSS on the hook is ignored there too. It is read as a token on every platform — none of them writes a bare 0. |
Stepper
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
indicatorSize | space.6 | 24px | 24px | no | part indicator. |
indicatorColor | color.foreground | #2a2b2f | #ecedee | yes | part indicator: The numeral on an upcoming or current step; the complete and error states have their own foregrounds. |
indicatorBackground | color.control.background | #ffffff | #2a2b2f | no | part indicator. |
indicatorBorder | color.border.strong | #939599 | #707176 | yes | part indicator. |
indicatorBorderWidth | border.width.focus | 2px | 2px | yes | part indicator. |
indicatorCompleteBackground | color.control.selectedBackground | #3553d2 | #496ded | yes | part indicator. |
indicatorCompleteForeground | color.control.selectedForeground | #ffffff | #000000 | yes | part indicator: The check Icon on a complete step, passed as the Icon’s color override. |
indicatorCompleteBorder | color.control.selectedBackground | #3553d2 | #496ded | yes | part indicator: The ring of a complete indicator, the same colour as its fill. |
indicatorRadius | radius.full | 999px | 999px | no | part indicator: Makes the indicator a circle. |
indicatorCurrentBorder | color.control.selectedBackground | #3553d2 | #496ded | yes | part indicator: The ring of a step whose resolved status is current; an error on the id-matched step shows the error ring instead. |
indicatorErrorBackground | color.status.danger.background | #fff2f0 | #4c0005 | yes | part indicator. |
indicatorErrorForeground | color.status.danger.foreground | #b51d26 | #ffc3bd | yes | part indicator: The danger Icon on an error step, passed as the Icon’s color override, as indicatorCompleteForeground is for the check. |
indicatorErrorBorder | color.status.danger.icon | #b51d26 | #ff958d | yes | part indicator: The ring; the danger icon step is the one guaranteed 3:1 against the page. |
indicatorFontSize | font.size.sm | 13px | 13px | no | part indicator: The numeral. The check and danger Icons take size="sm" and receive this as their size override, so glyph and numeral match. A font-size token driving an icon box is intended here (both resolve to a length, and the point is that the glyph tracks the numeral); because an override always wins over the prop, size="sm" on those two Icons is decorative. |
indicatorFontWeight | font.weight.semibold | 600 | 600 | no | part indicator. |
connector | color.border | #d9dbdd | #414246 | no | part connector. |
connectorComplete | color.control.selectedBackground | #3553d2 | #496ded | yes | part connector: The connector after step i when step i is before the current step by position; explicit statuses (error included) do not change it. Every other connector uses connector. |
connectorWidth | border.width.focus | 2px | 2px | yes | part connector. |
labelColor | color.foreground | #2a2b2f | #ecedee | yes | part label: Realised by the label Text’s tone default on every step that is not upcoming; no —ds-stepper-* hook. |
labelUpcomingColor | color.foreground.muted | #56585c | #bcbec1 | yes | part label: Realised by the label Text’s tone muted on a step whose resolved status is upcoming (an explicit status: upcoming is muted, an explicit current after the id match is not); no —ds-stepper-* hook. The label’s tone is passed per step, alongside the listed props. |
labelWeight | font.weight.medium | 500 | 500 | no | part label: Forwarded to the label Text’s fontWeight override on every step but the current one. |
labelCurrentWeight | font.weight.semibold | 600 | 600 | no | part label: Forwarded to the label Text’s fontWeight override on the current step (the id match). |
labelSize | font.size.sm | 13px | 13px | no | part label: Forwarded to the label Text’s fontSize override. |
descriptionColor | color.foreground.muted | #56585c | #bcbec1 | yes | part description: Realised by the description Text’s tone muted; no —ds-stepper-* hook. |
descriptionSize | font.size.xs | 11px | 11px | no | part description: Forwarded to the description Text’s fontSize override. |
countColor | color.foreground.muted | #56585c | #bcbec1 | yes | part count: Realised by the count Text’s tone muted; no —ds-stepper-* hook. |
countSize | font.size.sm | 13px | 13px | no | part count: Forwarded to the count Text’s fontSize override. |
stepHover | color.action.ghost.backgroundHover | #f6f7f8 | #2a2b2f | no | part step, state hover: Hover and press background of a navigable step (on React Native while the Pressable is pressed or hovered). |
stepRadius | radius.sm | 2px | 2px | no | part step. |
stepPadding | space.2 | 8px | 8px | no | part step: Inline padding of a step control (block padding too when vertical), so the hover background has room around the indicator and label. The step part is the list item (web/Lit <li>); stepHover, stepRadius, stepPadding, minTarget and the focus ring style the control inside it. |
stepGap | layout.gap.normal | 8px | 8px | no | part step: Between steps along the orientation axis (the connector fills it). Horizontally the connector grows with free inline space from a minimum of stepGap; vertically there is no free space in a column of auto-height steps, so a vertical connector is exactly stepGap tall. It is centred on the indicator’s axis, and the offset takes stepPadding only on the axis the padding is on: horizontally calc((indicatorSize − connectorWidth) / 2) as the block offset, vertically calc(stepPadding + (indicatorSize − connectorWidth) / 2) as the inline offset. It does not extend into stepPadding along the orientation axis, so the line stops short of the neighbouring indicators. Where the calc has no equivalent (React Native), the connector track is made exactly indicatorSize on the cross axis and inset by the focus border instead, which lands on the same centre line. |
partGap | space.2 | 8px | 8px | no | part step: Inside the step control, between the indicator and its label (and between label and description). Not the control’s padding; that is stepPadding. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part list: Set on the root and forwarded to each composed Text’s fontFamily override. On React Native a View carries no text style and nothing inherits, so there it is set on the indicator numeral — the only text Stepper draws itself — and reaches every other string through the forwards. |
minTarget | size.target.min | 24px | 24px | yes | part step: Minimum block and inline size of a navigable step control. Scoped to the navigable control, as focusRing and stepHover are: a display-only step is not a target and carries no floor. Both kinds of step still carry the transparent focusRingWidth border, so navigable and display-only steps occupy the same box and stay aligned in one list. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part step: Focus-visible ring on a navigable step control. Drawn outside the control on web and Lit; on React Native, which has no outline, it is a border inside the box, so every step control reserves focusRingWidth as a transparent border and the connector geometry compensates for it. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part step: Width of the focus-visible ring on a navigable step control. |
transition | motion.duration.fast | 120ms | 120ms | no | part connector. |
Switch
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
trackOff | color.control.trackOff | #939599 | #707176 | yes | part track. |
trackOn | color.control.selectedBackground | #3553d2 | #496ded | yes | part track. |
thumb | color.control.selectedForeground | #ffffff | #000000 | yes | part thumb: Thumb color in both states. Web and Lit draw the thumb the same way: a Switch-owned aria-hidden with pointer-events: none, stacked over the input in a track wrapper span (as Checkbox’s indicator), carrying data-part="thumb" (web) / part="thumb" and data-part="thumb" (Lit). Not a ::before of the input: Firefox draws no pseudo-element on an appearance: none input. Its state styles key off the input’s [aria-checked='true'] (the component state, so controlled mode looks right), never :checked. RN: no view (the native Switch draws it), so there is no Switch.thumb testID. SwiftUI: a shape in the ToggleStyle. |
trackWidth | space.10 | 40px | 40px | no | part track: Overridable on web, Lit and SwiftUI. RN: drawn by the native Switch, so it is not in the RN overridable binding type. |
trackHeight | space.6 | 24px | 24px | no | part track: As trackWidth: not overridable on RN. |
thumbSize | space.5 | 20px | 20px | no | part thumb: Thumb diameter; it travels trackWidth − thumbSize − 2 × thumbInset. Not overridable on RN. |
thumbInset | space.1 | 4px | 4px | no | part thumb: The inline gap between the thumb and the track edge, at each end. On the block axis the inset is derived from the geometry, (trackHeight − thumbSize) / 2, not taken from this binding: the thumb is always centred across the track, so an override of thumbInset or trackHeight moves the ends without ever pushing the thumb off centre. At the default tokens the two happen to agree. Not overridable on RN. |
radius | radius.full | 999px | 999px | no | part track: Applies to both the track and the thumb. Not overridable on RN. |
gap | space.3 | 12px | 12px | no | part label: Gap between track and label (the row’s flex gap). The gap is part of the hit area: a click or press on it toggles. |
partGap | space.1 | 4px | 4px | no | part description: Vertical gap between label and description (the text column). |
labelColor | color.foreground | #2a2b2f | #ecedee | yes | part label. |
labelSize | font.size.md | 16px | 16px | no | part label: Also sets the track alignment: the track sits in a slot one label line tall (labelSize × lineHeight), aligned with the top of the text column and centred on the label’s first line. The row centres its content (track slot plus text column) on the cross axis, so a one-line row is vertically centred in minTarget; a wrapping label or a description grows the content and the track stays on the first line. RN: the native Switch (about 31pt on iOS) can be taller than the line slot; it is centred on the slot and overflows it evenly above and below, and the row’s minHeight still contains it. |
labelWeight | font.weight.regular | 400 | 400 | no | part label. |
helperSize | font.size.sm | 13px | 13px | no | part description: Description text size. Reaches the composed Text only through its fontSize override, with fontFamily and lineHeight forwarded the same way; no —ds-switch-* hook. Rule for every binding that is only forwarded to a composed child (helperSize, descriptionText): it gets no —ds-switch-* CSS hook on web or Lit, so it is changed through the Switch overrides property only, never from CSS. |
descriptionText | color.foreground.muted | #56585c | #bcbec1 | yes | part description: Realised by the composed Text’s muted tone; no —ds-switch-* hook. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part label: The label’s own rule, and forwarded to the description Text. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part label: As fontFamily: the label’s own rule, forwarded to the description Text. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part track: Web/Lit/SwiftUI only. RN: the native Switch has no focus events, so the OS focus indicator is used and this binding is not applied. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part track: Drawn around the track, offset by focusRingWidth like every other control. Not applied on RN (OS indicator). |
minTarget | size.target.comfortable | 44px | 44px | yes | part label: Minimum block size of the full-width root (web/Lit: min-block-size; RN: the root’s minHeight). It is carried by the root, which is a column box that centres a single inner content row — an unnamed box with no part and no hook — and that inner row is what top-aligns the track slot against the text column. One box cannot both centre itself in minTarget and top-align its items, and Switch takes the two-box route rather than Checkbox’s symmetric padding, which this component’s rows forbid. The root has no vertical padding, and the whole root toggles: the empty band above and below the content row inside minTarget is hit area too, as is the gap between label and track. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | part track: Dims the track (with its thumb), the label and the description. Web/Lit: on the track wrapper and on the text column wrapper that holds the label and the description, never on the composed description Text itself. RN: on the whole Pressable row (track, label and description), unlike Checkbox, which leaves its description undimmed. |
transition | motion.duration.fast | 120ms | 120ms | no | part thumb: Thumb travel and track color, with motion.easing.standard. Under reduced motion both are removed: the thumb jumps and the color changes instantly. Not applied on RN (OS animation). |
Table
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
surface | color.background | #ffffff | #17181b | yes | |
headerSurface | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part header. |
headerColor | color.foreground | #2a2b2f | #ecedee | yes | part header. |
headerWeight | font.weight.semibold | 600 | 600 | no | part header. |
headerSize | font.size.sm | 13px | 13px | no | part header. |
headerBorder | color.border.strong | #939599 | #707176 | no | part header. |
headerBorderWidth | border.width.thin | 1px | 1px | no | part header. |
headerShadow | shadow.raised | 0px 1px 3px 0px #0000001a | 0px 1px 3px 0px #00000059 | no | part header: Shown under the sticky header only once the body has scrolled beneath it. |
rowBorder | color.border | #d9dbdd | #414246 | no | part row. |
rowBorderWidth | border.width.thin | 1px | 1px | no | part row. |
rowStripe | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part row. |
rowHover | color.action.ghost.backgroundHover | #f6f7f8 | #2a2b2f | no | part row, state hover: Interactive rows only (onRowPress or a Link in the row header). Hover never appears on plain rows. A render function’s output cannot be inspected, so web and Lit find the link with a :has() selector on the row-header cell — hover alone, with neither the pointer cursor nor the row press that onRowPress rows get — and React Native, which has no such selector, tints only onRowPress rows. |
rowSelected | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part row: Same tint as a stripe; the start-edge bar and the checkbox are what say selected. |
rowSelectedBorder | color.control.selectedBackground | #3553d2 | #496ded | yes | part row: A start-edge bar on selected rows, so selection is not color-fill alone. It never shifts content: web and Lit draw it as an inset box-shadow on the row’s first cell (on the row itself when stacked), mirrored under RTL; React Native always reserves a start border of rowSelectedBorderWidth, coloured as the row background on an unselected row in the column layout and as rowBorder on an unselected stacked block, so the block’s outline stays unbroken. |
rowSelectedBorderWidth | border.width.focus | 2px | 2px | yes | part row. |
cellColor | color.foreground | #2a2b2f | #ecedee | yes | part cell. |
cellMutedColor | color.foreground.muted | #56585c | #bcbec1 | yes | part cell: Secondary values (a date beside a title) rendered with Text tone muted. |
cellPaddingInline | layout.inset.md | 12px | 12px | no | part cell. |
cellPaddingInline (density=compact) | layout.inset.sm | 8px | 8px | no | part cell. |
cellPaddingBlock | space.sm | 8px | 8px | no | part cell. |
cellGap | layout.gap.tight | 4px | 4px | no | part cell: Between a sort button’s label and its arrow, and between actions in the actions cell. |
captionSize | font.size.md | 16px | 16px | no | part caption. |
captionWeight | font.weight.semibold | 600 | 600 | no | part caption. |
captionGap | space.2 | 8px | 8px | no | part caption: Between caption and header. |
stackedRowInset | layout.inset.md | 12px | 12px | no | |
stackedRowGap | layout.gap.tight | 4px | 4px | no | Between label/value pairs inside a stacked row. |
stackedBlockGap | layout.gap.tight | 4px | 4px | no | Between stacked row blocks: the body’s gap (web and Lit: the stacked tbody grid; React Native: the list’s contentContainerStyle gap). Each block is outlined with rowBorder/rowBorderWidth on stackedRowRadius. |
stackedLabelColor | color.foreground.muted | #56585c | #bcbec1 | yes | part stackedLabel. |
stackedLabelSize | font.size.xs | 11px | 11px | no | part stackedLabel. |
stackedLabelWeight | font.weight.medium | 500 | 500 | no | part stackedLabel. |
stackedRowRadius | radius.md | 4px | 4px | no | |
stickyColumnShadow | shadow.raised | 0px 1px 3px 0px #0000001a | 0px 1px 3px 0px #00000059 | no | responsive: scroll: the sticky row-header column casts this once scrolled. |
scrollFade | space.6 | 24px | 24px | no | Edge fade width on the scroll region; an edge fades only while columns are hidden past it, measured as the scroll offset against scrollWidth − clientWidth with a 1px tolerance and re-measured on scroll and on size changes, with the mask flipped under RTL. The mask covers the scrolling content only, never the region’s own focus ring, which stays whole at a faded edge: a mask clips everything its element paints, so on web and Lit the ring is drawn by an unnamed wrapper around the scroll region (:has() on the region’s :focus-visible) while the region itself sets outline: none. That wrapper is not an anatomy part and exists in this mode alone. React Native draws it with react-native-svg, as Toolbar does. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | |
fontSize | font.size.sm | 13px | 13px | no | |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | |
numericFont | font.family.mono | ui-monospace, SFMono-Regular, Menlo, Consolas, monospace | ui-monospace, SFMono-Regular, Menlo, Consolas, monospace | no | Body cells of columns with align end always use this family plus tabular figures (font-variant-numeric: tabular-nums on web and Lit) — no platform can detect whether the body font has tabular figures, so there is no fallback test. |
minTarget | size.target.min | 24px | 24px | yes | |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes | |
transition | motion.duration.fast | 120ms | 120ms | no | The interactive row’s hover background only; the sort arrow is a swapped Icon inside the Button and changes instantly, as does sorting itself. |
Tabs
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
tabColor | color.foreground.muted | #56585c | #bcbec1 | yes | part tab. |
tabSelectedColor | color.foreground.strong | #000000 | #ffffff | yes | part tab. |
tabHoverBackground | color.background.subtle | #f6f7f8 | #2a2b2f | yes | part tab, state hover. |
tabPaddingBlock | space.sm | 8px | 8px | no | part tab. |
tabPaddingInline | space.md | 12px | 12px | no | part tab. |
tabGap | layout.gap.tight | 4px | 4px | no | part tab: Between icon, label and badge inside a tab. |
listGap | layout.gap.none | 0px | 0px | no | part tablist: Tabs touch; the indicator separates them. |
indicator | color.control.selectedBackground | #3553d2 | #496ded | yes | part indicator: The selected tab’s underline (horizontal, flush against the list border at the bottom edge) or side bar (vertical, flush against the inline-end edge next to the panels). It sits inside the list at inset 0 of that edge, directly against the border rather than over it, because the scrolling list clips anything outside its padding box. It is placed instantly on first render and when the selected tab first appears, and animates only when the selection moves between tabs: the component gates its own transition (web: a data-animate attribute on the indicator part, an implementation hook rather than a declared attribute; React Native: a has-measured latch, so the first measured placement snaps), and a resize remeasure never animates. It is positioned from the selected tab’s measured offset within the list, which is a requirement stated in measurements and not in a formula — each platform computes it in the direction it can (web from the list’s inline-start edge, so RTL is measured from the right; Lit anchors at the start edge and translates; React Native branches on I18nManager.isRTL, since its position and border props are physical). With a selection that matches no tab — a value with no entry, or an empty tabs array — it renders at zero size rather than staying on the last tab. The colour is the selected-control fill, which is chosen per mode to meet 3:1 on the page (the primary button fill is not). |
indicatorThickness | border.width.focus | 2px | 2px | yes | part indicator. |
listBorder | color.border | #d9dbdd | #414246 | no | part tablist: The rule under the whole tab list (horizontal), or along its inline-end edge (vertical). |
listBorderWidth | border.width.thin | 1px | 1px | no | part tablist. |
panelGap | layout.gap.loose | 16px | 16px | no | part panel: Between the tab list and the panel. Applied as the gap of the root flex layout that holds the list and the panels (the host on Lit), not as a margin on each panel; part: panel names what the gap sits before, not the element that carries it. |
badgeColor | color.foreground.muted | #56585c | #bcbec1 | yes | part tabBadge. |
badgeWeight | font.weight.regular | 400 | 400 | no | part tabBadge: The badge’s font weight; lighter than the label’s fontWeight so the count reads as secondary. |
badgeSize | font.size.xs | 11px | 11px | no | part tabBadge: The badge’s font size; its line height is the same lineHeight multiplier as the label, applied to this size. That works because font.lineHeight.normal is unitless in every theme — a themed value with units would become a fixed length here. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | part tab. |
fontSize | font.size.md | 16px | 16px | no | part tab. |
fontWeight | font.weight.medium | 500 | 500 | no | part tab. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | part tab. |
radius | radius.sm | 2px | 2px | no | part tab: On the tab’s hover background and focus ring. |
minTarget | size.target.comfortable | 44px | 44px | yes | part tab: Minimum block size and minimum inline size of every tab (both axes). |
focusRing | color.border.focus | #496ded | #99b5ff | yes | part tab: Also the focusable panel’s ring: the panel has no binding of its own and draws from these same two hooks. |
focusRingWidth | border.width.focus | 2px | 2px | yes | part tab. |
transition | motion.duration.fast | 120ms | 120ms | no | part indicator: Indicator movement, with motion.easing.standard; instant under reduced motion. |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | part tab. |
Text
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | |
fontSize | font.size.xs | 11px | 11px | no | |
fontSize | font.size.sm | 13px | 13px | no | |
fontSize | font.size.md | 16px | 16px | no | |
fontSize | font.size.lg | 19px | 19px | no | |
fontSize | font.size.xl | 23px | 23px | no | |
fontWeight | font.weight.regular | 400 | 400 | no | |
fontWeight | font.weight.medium | 500 | 500 | no | |
fontWeight | font.weight.semibold | 600 | 600 | no | |
fontWeight | font.weight.bold | 700 | 700 | no | |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | |
color | color.foreground | #2a2b2f | #ecedee | yes | Locked, and locked means absent from the overridable type — so a composing parent that forwards { color: … } gets a compile error, not a silent no-op. A surface with its own foreground (Toast, Tooltip, Slider’s value bubble on color.inverse.foreground) therefore does not recolour Text at all: on web and Lit it re-scopes --color-foreground on its own container, which the tone resolves through and which touches no part of the child; on React Native, where there is no cascade, it provides the package-internal TextForegroundContext, which Text reads only while tone is default. onAction is not a stand-in for either — in dark mode it is near-white while the inverse foreground is near-black. A control that paints its own selected text (a DatePicker day) draws that text itself rather than asking Text for a colour it has no tone for. Locked, it still keeps its --ds-text-color hook on web and Lit, as every locked binding does, and the tone rule reads the hook. Each tone sets the hook’s default to the token’s own custom property — default is the bare var(--color-foreground) an inverse surface re-scopes, and onAction is var(--color-foreground-on-action) (camelCase to kebab-case) — so re-scoping --color-foreground on a container still recolours the default tone. TextForegroundContext exists on React Native only: it is exported from the rn Text.tsx for sibling components and not re-exported from the package index. The web Text.tsx has no such export. |
color | color.foreground.strong | #000000 | #ffffff | yes | Locked, and locked means absent from the overridable type — so a composing parent that forwards { color: … } gets a compile error, not a silent no-op. A surface with its own foreground (Toast, Tooltip, Slider’s value bubble on color.inverse.foreground) therefore does not recolour Text at all: on web and Lit it re-scopes --color-foreground on its own container, which the tone resolves through and which touches no part of the child; on React Native, where there is no cascade, it provides the package-internal TextForegroundContext, which Text reads only while tone is default. onAction is not a stand-in for either — in dark mode it is near-white while the inverse foreground is near-black. A control that paints its own selected text (a DatePicker day) draws that text itself rather than asking Text for a colour it has no tone for. Locked, it still keeps its --ds-text-color hook on web and Lit, as every locked binding does, and the tone rule reads the hook. Each tone sets the hook’s default to the token’s own custom property — default is the bare var(--color-foreground) an inverse surface re-scopes, and onAction is var(--color-foreground-on-action) (camelCase to kebab-case) — so re-scoping --color-foreground on a container still recolours the default tone. TextForegroundContext exists on React Native only: it is exported from the rn Text.tsx for sibling components and not re-exported from the package index. The web Text.tsx has no such export. |
color | color.foreground.muted | #56585c | #bcbec1 | yes | Locked, and locked means absent from the overridable type — so a composing parent that forwards { color: … } gets a compile error, not a silent no-op. A surface with its own foreground (Toast, Tooltip, Slider’s value bubble on color.inverse.foreground) therefore does not recolour Text at all: on web and Lit it re-scopes --color-foreground on its own container, which the tone resolves through and which touches no part of the child; on React Native, where there is no cascade, it provides the package-internal TextForegroundContext, which Text reads only while tone is default. onAction is not a stand-in for either — in dark mode it is near-white while the inverse foreground is near-black. A control that paints its own selected text (a DatePicker day) draws that text itself rather than asking Text for a colour it has no tone for. Locked, it still keeps its --ds-text-color hook on web and Lit, as every locked binding does, and the tone rule reads the hook. Each tone sets the hook’s default to the token’s own custom property — default is the bare var(--color-foreground) an inverse surface re-scopes, and onAction is var(--color-foreground-on-action) (camelCase to kebab-case) — so re-scoping --color-foreground on a container still recolours the default tone. TextForegroundContext exists on React Native only: it is exported from the rn Text.tsx for sibling components and not re-exported from the package index. The web Text.tsx has no such export. |
color | color.foreground.danger | #b51d26 | #ff958d | yes | Locked, and locked means absent from the overridable type — so a composing parent that forwards { color: … } gets a compile error, not a silent no-op. A surface with its own foreground (Toast, Tooltip, Slider’s value bubble on color.inverse.foreground) therefore does not recolour Text at all: on web and Lit it re-scopes --color-foreground on its own container, which the tone resolves through and which touches no part of the child; on React Native, where there is no cascade, it provides the package-internal TextForegroundContext, which Text reads only while tone is default. onAction is not a stand-in for either — in dark mode it is near-white while the inverse foreground is near-black. A control that paints its own selected text (a DatePicker day) draws that text itself rather than asking Text for a colour it has no tone for. Locked, it still keeps its --ds-text-color hook on web and Lit, as every locked binding does, and the tone rule reads the hook. Each tone sets the hook’s default to the token’s own custom property — default is the bare var(--color-foreground) an inverse surface re-scopes, and onAction is var(--color-foreground-on-action) (camelCase to kebab-case) — so re-scoping --color-foreground on a container still recolours the default tone. TextForegroundContext exists on React Native only: it is exported from the rn Text.tsx for sibling components and not re-exported from the package index. The web Text.tsx has no such export. |
color | color.foreground.onAction | #ffffff | #ffffff | yes | Locked, and locked means absent from the overridable type — so a composing parent that forwards { color: … } gets a compile error, not a silent no-op. A surface with its own foreground (Toast, Tooltip, Slider’s value bubble on color.inverse.foreground) therefore does not recolour Text at all: on web and Lit it re-scopes --color-foreground on its own container, which the tone resolves through and which touches no part of the child; on React Native, where there is no cascade, it provides the package-internal TextForegroundContext, which Text reads only while tone is default. onAction is not a stand-in for either — in dark mode it is near-white while the inverse foreground is near-black. A control that paints its own selected text (a DatePicker day) draws that text itself rather than asking Text for a colour it has no tone for. Locked, it still keeps its --ds-text-color hook on web and Lit, as every locked binding does, and the tone rule reads the hook. Each tone sets the hook’s default to the token’s own custom property — default is the bare var(--color-foreground) an inverse surface re-scopes, and onAction is var(--color-foreground-on-action) (camelCase to kebab-case) — so re-scoping --color-foreground on a container still recolours the default tone. TextForegroundContext exists on React Native only: it is exported from the rn Text.tsx for sibling components and not re-exported from the package index. The web Text.tsx has no such export. |
Toast
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
surface | color.inverse.surface | #17181b | #ecedee | yes | Inverted like Tooltip: dark on light, light on dark, so it floats above any page surface. |
text | color.inverse.foreground | #f6f7f8 | #17181b | yes | part message: Text’s color is locked and has no inverse tone, so the toast re-scopes the foreground on its own toast container, not on the message part (--color-foreground on web and Lit, TextForegroundContext on React Native) and composes Text unchanged, as Tooltip does. |
icon | color.inverse.status.neutral | #f6f7f8 | #17181b | yes | part icon: neutral renders no icon; the other tones render Icon name={tone} at its default size, with this token forwarded to Icon’s overrides.color, using the status step chosen to read on the inverse surface. |
icon | color.inverse.status.success | #76d07a | #00791d | yes | part icon: neutral renders no icon; the other tones render Icon name={tone} at its default size, with this token forwarded to Icon’s overrides.color, using the status step chosen to read on the inverse surface. |
icon | color.inverse.status.warning | #e6ac3f | #825b00 | yes | part icon: neutral renders no icon; the other tones render Icon name={tone} at its default size, with this token forwarded to Icon’s overrides.color, using the status step chosen to read on the inverse surface. |
icon | color.inverse.status.danger | #ff958d | #b51d26 | yes | part icon: neutral renders no icon; the other tones render Icon name={tone} at its default size, with this token forwarded to Icon’s overrides.color, using the status step chosen to read on the inverse surface. |
actionColor | color.inverse.link | #99b5ff | #243ab3 | yes | The action and dismiss Buttons are rendered with Button’s inverse prop (ghost variant), which is how a composite gets an on-inverse child without restyling it. |
dismissColor | color.inverse.link | #99b5ff | #243ab3 | yes | The dismiss and action Buttons are ghost + inverse, whose text is color.inverse.link; Toast never restyles them. |
focusRingInverse | color.inverse.focus | #99b5ff | #243ab3 | yes | Focus ring color on the inverse surface, replacing color.border.focus inside the toast. |
radius | radius.md | 4px | 4px | no | |
shadow | shadow.overlay | 0px 8px 24px 0px #0000002e | 0px 8px 24px 0px #00000080 | no | |
paddingBlock | space.sm | 8px | 8px | no | |
paddingInline | space.md | 12px | 12px | no | |
gap | layout.gap.normal | 8px | 8px | no | Between icon, message, action and dismiss. |
stackGap | layout.gap.tight | 4px | 4px | no | Between stacked toasts in the region. stackGap, regionInset and layer belong to the region (ToastRegion / ds-toast-region / ToastProvider) and are overridable through the region’s own overrides, not on a toast; their CSS hooks keep the toast prefix on web and Lit: --ds-toast-stack-gap, --ds-toast-region-inset, --ds-toast-layer. The region’s three keys form their own binding group, accepted by the region and absent from a toast’s overrides; where a platform has only one union (React Native), a standalone Toast accepts them and they do nothing. |
regionInset | layout.gutter | 24px | 24px | no | part region: Distance of the region from the viewport edge (bottom-start on wide screens, bottom center on phones; on web and Lit plus env(safe-area-inset-bottom)). “Wide” is the resolved px of layout.maxWidth.content read from the default theme at generation time and marked literal-ok, as Container does, since custom properties cannot be used in media queries. |
maxWidth | layout.maxWidth.prose | 572px | 572px | no | |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | fontFamily, fontSize and lineHeight are forward-only: they reach the message Text through its overrides, have no --ds-toast-* hook, and the value (override or this default) is always passed. |
fontSize | font.size.md | 16px | 16px | no | |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | |
minTarget | size.target.min | 24px | 24px | yes | The minimum block size of the toast row, so a toast is never shorter than a touch target even with no buttons in it. The action and dismiss Buttons size their own targets; Toast never applies this to them. |
layer | layer.toast | 400 | 400 | no | The region’s stacking layer — one of the region’s three bindings, like stackGap and regionInset, and overridable only through the region. |
enter | motion.duration.base | 200ms | 200ms | no | Rise and fade; instant under reduced motion. |
enterOffset | space.2 | 8px | 8px | no | Distance the toast rises during enter; none under reduced motion. |
exit | motion.duration.fast | 120ms | 120ms | no | The reverse of enter: sinks by enterOffset while fading; instant under reduced motion. Resolved at runtime like the two durations, so an override is honoured; an unresolvable value (jsdom, no theme CSS) removes the toast at once. |
Toolbar
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
background | color.background.subtle | #f6f7f8 | #2a2b2f | yes | |
border | color.border | #d9dbdd | #414246 | no | |
borderWidth | border.width.thin | 1px | 1px | no | |
radius | radius.md | 4px | 4px | no | |
paddingInline | space.2 | 8px | 8px | no | |
paddingBlock | space.1 | 4px | 4px | no | |
itemGap | layout.gap.normal | 8px | 8px | no | Between adjacent controls, inside a group and between ungrouped top-level controls alike. An override replaces the value at both densities. |
itemGap (density=compact) | layout.gap.tight | 4px | 4px | no | Between adjacent controls, inside a group and between ungrouped top-level controls alike. An override replaces the value at both densities. |
groupGap | layout.gap.normal | 8px | 8px | no | part separator: Either side of a separator, replacing itemGap there (not added to it). Every platform applies it the same way: the row’s gap stays itemGap and the separator part gets inline padding (block padding when vertical) of groupGap − itemGap, clamped at 0, so an override smaller than itemGap has no effect. The clamp needs a zero length of its own: max(0px, calc(…)) on web and Lit (a literal-ok zero, not a token), Math.max(0, …) on React Native. |
separatorLength | space.5 | 20px | 20px | no | part separator: The Divider between groups is shorter than the toolbar height. Divider has no length binding, so this is the block size (inline size when vertical) of the separator wrapper element, which carries the part hook; the Divider inside it has orientation across the toolbar axis, spacing: none, and stretches to fill the wrapper. Being shorter than the toolbar, the separator is centred on the cross axis. |
fadeWidth | space.6 | 24px | 24px | no | Edge fade for a scrolling row — overflow: scroll, and menu on React Native, which renders as scroll there; wrap never fades. The fade sits over the scrolling row only, inside the toolbar’s padding, so the border and the padding stay unclouded. Drawn as a mask-image gradient on web and Lit (so the content fades into whatever is behind the toolbar, with no second painted layer) and as a react-native-svg gradient from the toolbar background to transparent on React Native. Each edge fades only while content is hidden past it, re-checked on scroll and on size changes. The faded edges are physical — left and right, top and bottom on a vertical toolbar, which scrolls vertically — so RTL needs no special case. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | |
focusRingWidth | border.width.focus | 2px | 2px | yes |
Tooltip
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
surface | color.inverse.surface | #17181b | #ecedee | yes | Inverted: the tooltip is dark on light mode and light on dark mode, so it reads as a label, not a panel. |
text | color.inverse.foreground | #f6f7f8 | #17181b | yes | part text: Text’s color is locked and its tones have no inverse value, so the bubble re-scopes the foreground on its own container and composes Text unchanged — the mechanism Text’s own color binding names, not an override and not a restyle. |
radius | radius.sm | 2px | 2px | no | |
paddingBlock | space.1 | 4px | 4px | no | |
paddingInline | space.2 | 8px | 8px | no | |
offset | space.1 | 4px | 4px | no | Gap between trigger and tooltip. |
maxWidth | space.20 | 80px | 80px | no | Longer text wraps. An override replaces the base token, not the whole expression: the rule is (override ?? space.20) × 3 on every platform. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | Forward-only, as fontSize and lineHeight are: the value (this default or an override) is always passed to the bubble’s Text through its overrides, and there is no --ds-tooltip-font-family hook. A consumer restyles tooltip typography through Text’s own --ds-text-* hooks, which the whole subtree inherits. |
fontSize | font.size.sm | 13px | 13px | no | Forward-only like fontFamily. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | Forward-only like fontFamily. |
shadow | shadow.raised | 0px 1px 3px 0px #0000001a | 0px 1px 3px 0px #00000059 | no | |
layer | layer.toast | 400 | 400 | no | Tooltips sit above everything, including dialogs, because they describe controls inside them. overlay.layer: tooltip names the stacking category; this token is its value. |
enter | motion.duration.fast | 120ms | 120ms | no | Fade in; instant under reduced motion. |
exit | motion.duration.fast | 120ms | 120ms | no | Fade out, opacity only with no slide; instant under reduced motion. |
Tree
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
indent | space.5 | 20px | 20px | no | part indent: Per level on the node row. |
rowHeight | size.target.min | 24px | 24px | yes | |
rowPaddingInline | space.2 | 8px | 8px | no | |
rowRadius | radius.sm | 2px | 2px | no | |
rowGap | layout.gap.tight | 4px | 4px | no | Between the expand button, icon, label and badge; the checkbox is followed by checkboxGap instead. |
rowHover | color.action.ghost.backgroundHover | #f6f7f8 | #2a2b2f | no | state hover: On native it is the pressed fill (and the react-native-web hover), swapped instantly; a selected row keeps rowSelected. |
rowSelected | color.background.strong | #ecedee | #414246 | yes | Fill on the selected node, and on aria-checked=“true” nodes in multiple mode. An indeterminate (mixed) parent gets neither this fill nor the start-edge bar — its dash glyph is the only signal. |
rowSelectedBorder | color.control.selectedBackground | #3553d2 | #496ded | yes | Start-edge bar on the selected (or checked) node, as Table and DataGrid. Drawn over the row at the logical start and mirrored in RTL, by whichever means the platform has that does not move the content (an inset shadow on the row, or an absolutely positioned box). |
rowSelectedBorderWidth | border.width.focus | 2px | 2px | yes | |
labelColor | color.foreground | #2a2b2f | #ecedee | yes | part label: The label Text’s default tone; Text.color is locked, so it is not forwarded. |
labelSelectedWeight | font.weight.medium | 500 | 500 | no | part label: Forwarded to the label Text as overrides.fontWeight when selected. When a node has href the label Text only wraps the composed link and the weight is not forwarded, so a selected navigation node is marked by the row rather than by heavier text. |
headingSize | font.size.md | 16px | 16px | no | part heading: The visible label Heading; forwarded as overrides.fontSize, which is what the label actually renders at. The Heading is composed at size: md, the size this token matches, so the two agree at the default and an override of headingSize alone moves the rendered size without moving the Heading’s own size. The gap below it is the Heading’s own marginBlockEnd. |
iconColor | color.foreground.muted | #56585c | #bcbec1 | yes | part icon. |
badgeColor | color.foreground.muted | #56585c | #bcbec1 | yes | part badge: The badge Text’s tone="muted"; Text.color is locked, so it is not forwarded. |
badgeSize | font.size.xs | 11px | 11px | no | part badge: Forwarded to the badge Text as overrides.fontSize. |
expandButtonSize | size.target.min | 24px | 24px | yes | part expandButton: The expandButton part is a wrapper the tree owns, this size on both axes, around an unmodified ghost Button centred inside it (Button has no size override and keeps its own data-part, and a sm Button is smaller than this, so the target is the wrapper, not the Button); the chevron takes the Button’s own color. TreeGrid’s expandButton is the same wrapper-around-a-Button shape. |
guideLine | color.border | #d9dbdd | #414246 | no | One vertical line per open parent, drawn down that parent’s group, at rowPaddingInline + indent × (parent level − 1) + expandButtonSize / 2 from the row’s start — the centre of that parent’s chevron. No elbows, no termination at the last child, as TreeGrid. |
guideLineWidth | border.width.thin | 1px | 1px | no | |
checkboxGap | layout.gap.tight | 4px | 4px | no | part checkbox: In multiple mode the row reads chevron, checkbox, icon, label, badge; this is the gap after the checkbox, so on a node with an icon it falls between the checkbox and the icon. Every other gap in the row is rowGap, which means the row is two nested groups rather than one gap (checkbox beside the rest at checkboxGap; chevron, icon, label and badge at rowGap) — one flex gap cannot differ per pair. In single and none no checkbox renders and this binding has no effect. |
checkboxSize | space.4 | 16px | 16px | no | part checkbox: The drawn checkbox glyph in multiple mode (the treeitem is the control; no Checkbox component). |
checkboxBorder | color.control.border | #939599 | #707176 | yes | part checkbox: Unchecked edge; checked or mixed, the border takes checkboxSelected so the fill has no contrasting edge. |
checkboxBorderWidth | border.width.thin | 1px | 1px | no | part checkbox. |
checkboxBackground | color.control.background | #ffffff | #2a2b2f | no | part checkbox. |
checkboxSelected | color.control.selectedBackground | #3553d2 | #496ded | yes | part checkbox: Fill when checked or indeterminate. |
checkboxMark | color.control.selectedForeground | #ffffff | #000000 | yes | part checkbox: The check or dash Icon on the fill. |
checkboxRadius | radius.sm | 2px | 2px | no | part checkbox. |
fontFamily | font.family.body | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif | no | Sets the container’s font, and the resolved value (the override when there is one, the token otherwise) is always forwarded to the label Text as overrides.fontFamily, so the root hook and the composed label never disagree. The lazy placeholder and the empty-state Text take the same three resolved values at tone="muted"; a platform with no inherited font (React Native) forwards them there too rather than leaving Text’s own defaults. |
fontSize | font.size.sm | 13px | 13px | no | The container’s size and, resolved the same way, the label Text’s overrides.fontSize. |
lineHeight | font.lineHeight.normal | 1.5 | 1.5 | no | The container’s line height and, resolved the same way, the label Text’s overrides.lineHeight. |
minTarget | size.target.min | 24px | 24px | yes | |
focusRing | color.border.focus | #496ded | #99b5ff | yes | Drawn around the whole node — the chevron included, since the row is one visual target — not around nodeRow alone. It follows the treeitem’s own focus only: where the chevron Button is a focus target of its own (react-native-web), focusing it shows the Button’s own treatment and leaves the node’s ring unlit. |
focusRingWidth | border.width.focus | 2px | 2px | yes | |
disabledOpacity | opacity.disabled | 0.5 | 0.5 | no | |
transition | motion.duration.fast | 120ms | 120ms | no | Chevron rotation, and the row hover where the platform has one to animate (web and Lit); groups appear instantly, and the native pressed fill is swapped instantly as rowHover says. The collapsed chevron is mirrored in RTL. |
TreeGrid
| Binding | Token | Light | Dark | Locked | Description |
|---|---|---|---|---|---|
indent | space.5 | 20px | 20px | no | part indent: Per level: the indent part is a spacer at the start of the row header, indent × (level − 1) wide, so the cell keeps its own inline padding. Level 1 has none. |
expandButtonSize | size.target.min | 24px | 24px | yes | part expandButton: The expandButton part is the wrapper TreeGrid owns, this size on both axes, around an unmodified ghost Button centred inside it — the wrapper is the target and the inline width the guide lines align to, exactly as Tree’s expandButton, because Button has no size-override binding and a child is never restyled. minTarget is the locked row-height floor. |
expandGap | layout.gap.tight | 4px | 4px | no | Between the expand button and the row header text. |
guideLine | color.border | #d9dbdd | #414246 | no | One vertical line per ancestor level, drawn the full height of every descendant row and aligned with that ancestor’s expand button (indent guides; no elbows, no termination at the last child). Always drawn, at both densities. Drawn on the row rather than inside the row header cell, whose overflow is hidden and which may be pinned — either would clip them — at cellPaddingInline + indent × (ancestor level − 1) + expandButtonSize / 2 from the row header’s start, offset by the selection column when there is one. |
cellPaddingInline | space.2 | 8px | 8px | no | As DataGrid; also the base of the guide line offset. |
fixedHeight | space.20 | 80px | 80px | no | As DataGrid: the height for height: fixed, sizing the whole component with the scroll region taking what the caption and status bar leave; overridable by design. |
guideLineWidth | border.width.thin | 1px | 1px | no | |
parentWeight | font.weight.medium | 500 | 500 | no | Row headers of rows with children. |
loadingColor | color.foreground.muted | #56585c | #bcbec1 | yes | The lazy-loading placeholder text in a just-expanded row. It reaches the composed Text as tone="muted", which resolves to this token — Text.color is locked, so it is never forwarded as a colour. |
transition | motion.duration.fast | 120ms | 120ms | no | The chevron rotation on expand, and nothing else TreeGrid owns: rows appear instantly (no height animation in a virtualized grid), and the row hover and editor-open transitions are DataGrid’s, at DataGrid’s own token. |
focusRing | color.border.focus | #496ded | #99b5ff | yes | Drawn by the scrollRegion, as DataGrid’s focusRing is, while the grid inside it has focus — so the region’s own overflow never clips the ring. The ring on the active cell is DataGrid’s cellFocusRing, which TreeGrid inherits with the rest; these two bindings have no part of their own because the part they draw on is the one DataGrid names. |
focusRingWidth | border.width.focus | 2px | 2px | yes | |
minTarget | size.target.min | 24px | 24px | yes |