role button
Button draft v0.22.1
Triggers an action or event when activated.
A button lets people perform an action with a single activation: submit a form, open a dialog, confirm a choice. Its emphasis communicates how important the action is on the screen; its intent communicates what kind of action it is. Buttons never navigate; use a link for navigation.
Specification
From packages/components/specs/components/button.spec.json
Raw file packages/components/specs/components/button.spec.json
{
"$schema": "../schema/component.schema.json",
"id": "button",
"name": "Button",
"version": "0.22.1",
"status": "draft",
"role": "button",
"summary": "Triggers an action or event when activated.",
"description": "A button lets people perform an action with a single activation: submit a form, open a dialog, confirm a choice. Its emphasis communicates how important the action is on the screen; its intent communicates what kind of action it is. Buttons never navigate; use a link for navigation.",
"tags": [
"action",
"form"
],
"anatomy": [
{
"id": "container",
"role": "container",
"required": true,
"description": "Interactive root. Receives focus, carries background, border, size and the state styling."
},
{
"id": "icon",
"role": "icon",
"required": false,
"repeatable": true,
"decorative": true,
"description": "Optional glyph of the icon font before (leading) or after (trailing) the label; at most one on each side. Inherits the label color."
},
{
"id": "label",
"role": "label",
"required": true,
"description": "Text describing the action. Provides the accessible name."
}
],
"properties": [
{
"id": "emphasis",
"name": "Emphasis",
"type": "enum",
"default": "accent",
"description": "Visual weight of the button, from strongest to weakest. Use one accent button per view.",
"affects": [
"container",
"label"
],
"options": [
{
"value": "accent",
"description": "Highest emphasis: strong fill in the intent color, contrasting label."
},
{
"value": "subtle",
"description": "Medium emphasis: tinted fill and light border in the intent color."
},
{
"value": "ghost",
"description": "Lowest emphasis: label only at rest; a tinted fill and a light border appear on hover and press."
}
]
},
{
"id": "intent",
"name": "Intent",
"type": "enum",
"default": "neutral",
"description": "Kind of action, independent of emphasis. Every intent exists at every emphasis.",
"affects": [
"container",
"label"
],
"options": [
{
"value": "neutral",
"description": "Generic actions that carry no brand or status meaning."
},
{
"value": "brand",
"description": "Actions that carry the brand color: the main path through a flow."
},
{
"value": "error",
"description": "Destructive or irreversible actions (delete, remove, revoke)."
}
]
},
{
"id": "size",
"name": "Size",
"type": "enum",
"default": "medium",
"description": "Control height, inline padding, corner radius, text size and icon size scale together.",
"affects": [
"container",
"label",
"icon"
],
"options": [
{
"value": "xsmall",
"description": "Very dense UIs: inline in text-heavy tables, chips of actions. Exactly the 24 px minimum target on the web."
},
{
"value": "small",
"description": "Dense UIs, toolbars, table rows."
},
{
"value": "medium",
"description": "Default."
},
{
"value": "large",
"description": "Marketing surfaces, primary call to action on a page."
}
]
},
{
"id": "label",
"name": "Label",
"type": "string",
"required": true,
"bindsTo": "label",
"description": "Verb-first, no trailing punctuation; capitalization follows the content rules of the platform."
},
{
"id": "iconStart",
"name": "Leading icon",
"type": "icon",
"bindsTo": "icon",
"description": "Leading icon, reinforces the verb."
},
{
"id": "iconEnd",
"name": "Trailing icon",
"type": "icon",
"bindsTo": "icon",
"description": "Trailing icon, typically directional (arrow, chevron)."
},
{
"id": "disabled",
"name": "Disabled",
"type": "boolean",
"default": false,
"description": "Prevents activation; hover and active feedback no longer apply. Prefer keeping actions enabled and validating on activation."
}
],
"states": [
{
"id": "default",
"trigger": "interaction",
"description": "Resting state."
},
{
"id": "hover",
"trigger": "interaction",
"inputs": [
"pointer"
],
"description": "Pointer over the container. Needs a pointing device: touch has no hover. Fill and border move one step along the intent ramp; the label keeps its color."
},
{
"id": "active",
"trigger": "interaction",
"description": "Pointer, finger or key pressed down: the press state. Fill and border move a second step along the intent ramp."
},
{
"id": "focus-visible",
"trigger": "interaction",
"inputs": [
"keyboard"
],
"description": "Keyboard focus. Shows the shared focus ring outside the border; pointer focus shows none."
},
{
"id": "disabled",
"trigger": "property",
"property": "disabled",
"description": "Non-interactive. Colors drop to the shared disabled palette whatever the emphasis and intent; hover and active do not apply."
}
],
"layout": {
"direction": "horizontal",
"align": "center",
"justify": "center",
"wrap": false,
"sizing": "hug",
"notes": [
"Parts sit on one row separated by container.gap, which follows the size option.",
"Inline padding, minimum height and corner radius come from the size option; the block padding is a minimum: at the default text size the minimum height sets the height and the content centers vertically; larger content grows the button and keeps the block padding. With an icon, the side that holds it takes the icon-side padding and the label side of a one-icon button the label-side padding.",
"Width hugs the content; a layout may stretch it (full width) without changing the component."
]
},
"accessibility": {
"ariaRole": "button",
"name": {
"text": "Computed from the label text. Decorative icons are hidden from assistive technology and add nothing to the name.",
"refs": [
"apg-button-roles",
"accname-content",
"accname-hidden"
]
},
"keyboard": [
{
"key": "Tab",
"action": "Moves focus to the button. A disabled button is not focusable and is skipped.",
"refs": [
"wcag:2.1.1",
"html-focusable-area",
"html-disabled"
],
"platforms": [
"web"
]
},
{
"key": "Enter",
"action": "Activates the button.",
"refs": [
"apg-button-keyboard"
],
"platforms": [
"web"
]
},
{
"key": "Space",
"action": "Activates the button.",
"refs": [
"apg-button-keyboard"
],
"platforms": [
"web"
]
}
],
"guidelines": [
{
"text": "Expose the button role, its name and its disabled state through the platform's native button control rather than a custom view, so assistive technology receives them without extra work.",
"refs": [
"wcag:4.1.2",
"hig-buttons-style",
"compose-interactive-elements"
]
},
{
"text": "Use the native button element: it exposes the role, the name and the disabled state without ARIA. Do not add role=\"button\" to it.",
"refs": [
"html-aria-button",
"html-aam-button"
],
"platforms": [
"web"
]
},
{
"text": "Use a button for actions and a link for navigation: the role must match the function the control provides.",
"refs": [
"apg-button",
"hig-buttons"
]
},
{
"text": "Never rely on color alone to convey emphasis or intent: the label carries the meaning, and an error-intent button names the consequence of the action.",
"refs": [
"wcag:1.4.1",
"hig-accessibility-vision"
]
},
{
"text": "Label text contrasts at least 4.5:1 with the container background in every color mode, emphasis, intent and enabled state, on the default and subtle surfaces. Disabled buttons are inactive and exempt. Known exceptions, from the Figma library's error colors: the accent error fill at rest (4.46:1, both modes), the subtle error fill on hover and press and the ghost error press (4.35:1 and 3.28:1, light mode); tracked as a known issue.",
"refs": [
"wcag:1.4.3",
"hig-accessibility-vision"
]
},
{
"text": "The border is not what identifies the button: its label does, so the light borders of the subtle and ghost emphases, and the absent border of a ghost button at rest, need no 3:1 boundary contrast.",
"refs": [
"wcag:1.4.11"
]
},
{
"text": "A visible focus indicator appears when the button receives keyboard focus, and contrasts at least 3:1 with the adjacent colors in every color mode.",
"refs": [
"wcag:2.4.7",
"wcag:1.4.11"
]
},
{
"text": "The focus ring is 2.4 px thick and drawn outside the border, so it covers at least the area of a 2 CSS pixel thick perimeter of the button at 3:1 or more, as WCAG 2.4.13 Focus Appearance asks (AAA, adopted as extra quality).",
"refs": [
"wcag:2.4.13"
]
},
{
"text": "The focus ring is drawn with :focus-visible, so pointer focus shows no ring.",
"refs": [
"mdn-focus-visible"
],
"platforms": [
"web"
]
},
{
"text": "Every size is a pointer target of at least 24 x 24 CSS pixels through its minimum height and inline padding; extra small sits exactly at that minimum.",
"refs": [
"wcag:2.5.8"
]
},
{
"text": "Apple recommends a hit region of at least 44 x 44 pt. The extra small, small and medium buttons (24, 28 and 36 pt high) fall under it on iOS today: the Figma library has no platform heights.",
"refs": [
"hig-buttons-best-practices",
"hig-accessibility-mobility"
],
"platforms": [
"ios"
]
},
{
"text": "Android recommends a touch target of at least 48 x 48 dp. Every size (24, 28, 36 and 44 dp high) falls under it on Android today: the Figma library has no platform heights.",
"refs": [
"android-large-controls",
"compose-target-sizes"
],
"platforms": [
"android"
]
},
{
"text": "The visible label is the accessible name, so speech-input users can activate the button by saying what they see.",
"refs": [
"wcag:2.5.3"
]
},
{
"text": "Icons are decorative and hidden from assistive technology. The label alone must make sense.",
"refs": [
"wcag:1.1.1"
]
},
{
"text": "Hide each icon with aria-hidden=\"true\" so it adds nothing to the accessible name: an icon-font glyph is written as its ligature name, which would otherwise be read with the label.",
"refs": [
"aria-hidden",
"accname-hidden"
],
"platforms": [
"web"
]
},
{
"text": "Pass null as the icon's contentDescription, or hide it with hideFromAccessibility, so it adds nothing to the button's merged description.",
"refs": [
"compose-graphic-elements",
"android-describe-ui-element"
],
"platforms": [
"android"
]
},
{
"text": "The native disabled attribute removes the button from the focus order. When people need to discover why an action is unavailable, keep the button enabled and explain on activation, or keep it focusable with aria-disabled.",
"refs": [
"html-disabled",
"apg-disabled-focus"
],
"platforms": [
"web"
]
},
{
"text": "Always include a press state: without one a custom button feels unresponsive. The active state is that press state.",
"refs": [
"hig-buttons-best-practices"
],
"platforms": [
"ios"
]
},
{
"text": "Color transitions are removed when the user asks for reduced motion.",
"refs": [
"wcag:2.3.3"
]
},
{
"text": "The transition is switched off under prefers-reduced-motion: reduce.",
"refs": [
"mdn-reduced-motion"
],
"platforms": [
"web"
]
},
{
"text": "When the Reduce Motion setting is on, reduce automatic and repetitive animations.",
"refs": [
"hig-accessibility-cognitive"
],
"platforms": [
"ios"
]
}
],
"references": [
{
"id": "apg-button",
"title": "APG: Button pattern",
"url": "https://www.w3.org/WAI/ARIA/apg/patterns/button/"
},
{
"id": "apg-button-keyboard",
"title": "APG: Button pattern, keyboard interaction",
"url": "https://www.w3.org/WAI/ARIA/apg/patterns/button/#keyboardinteraction"
},
{
"id": "apg-button-roles",
"title": "APG: Button pattern, roles, states and properties",
"url": "https://www.w3.org/WAI/ARIA/apg/patterns/button/#roles_states_properties"
},
{
"id": "apg-disabled-focus",
"title": "APG: Focusability of disabled controls",
"url": "https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls"
},
{
"id": "html-aria-button",
"title": "ARIA in HTML: button element",
"url": "https://www.w3.org/TR/html-aria/#el-button"
},
{
"id": "html-aam-button",
"title": "HTML-AAM: button element",
"url": "https://www.w3.org/TR/html-aam-1.0/#el-button"
},
{
"id": "aria-hidden",
"title": "WAI-ARIA 1.2: aria-hidden",
"url": "https://www.w3.org/TR/wai-aria-1.2/#aria-hidden"
},
{
"id": "accname-content",
"title": "AccName 1.2: name from content (step 2F)",
"url": "https://www.w3.org/TR/accname-1.2/#comp_name_from_content"
},
{
"id": "accname-hidden",
"title": "AccName 1.2: hidden nodes are excluded (step 2A)",
"url": "https://www.w3.org/TR/accname-1.2/#comp_hidden_not_referenced"
},
{
"id": "html-disabled",
"title": "HTML: disabled form controls",
"url": "https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-fe-disabled"
},
{
"id": "html-focusable-area",
"title": "HTML: focusable area",
"url": "https://html.spec.whatwg.org/multipage/interaction.html#focusable-area"
},
{
"id": "mdn-focus-visible",
"title": "MDN: :focus-visible",
"url": "https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:focus-visible"
},
{
"id": "mdn-reduced-motion",
"title": "MDN: prefers-reduced-motion",
"url": "https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion"
},
{
"id": "hig-buttons",
"title": "Apple HIG: Buttons",
"url": "https://developer.apple.com/design/human-interface-guidelines/buttons"
},
{
"id": "hig-buttons-best-practices",
"title": "Apple HIG: Buttons, best practices (hit region, press state)",
"url": "https://developer.apple.com/design/human-interface-guidelines/buttons#Best-practices"
},
{
"id": "hig-buttons-style",
"title": "Apple HIG: Buttons, style (system buttons carry accessibility support)",
"url": "https://developer.apple.com/design/human-interface-guidelines/buttons#Style"
},
{
"id": "hig-accessibility-vision",
"title": "Apple HIG: Accessibility, vision (contrast, color alone)",
"url": "https://developer.apple.com/design/human-interface-guidelines/accessibility#Vision"
},
{
"id": "hig-accessibility-mobility",
"title": "Apple HIG: Accessibility, mobility (control sizes)",
"url": "https://developer.apple.com/design/human-interface-guidelines/accessibility#Mobility"
},
{
"id": "hig-accessibility-cognitive",
"title": "Apple HIG: Accessibility, cognitive (Reduce Motion)",
"url": "https://developer.apple.com/design/human-interface-guidelines/accessibility#Cognitive"
},
{
"id": "android-large-controls",
"title": "Android accessibility: use large, simple controls",
"url": "https://developer.android.com/guide/topics/ui/accessibility/apps#large-controls"
},
{
"id": "android-describe-ui-element",
"title": "Android accessibility: describe each UI element",
"url": "https://developer.android.com/guide/topics/ui/accessibility/apps#describe-ui-element"
},
{
"id": "compose-target-sizes",
"title": "Jetpack Compose accessibility: minimum touch target sizes",
"url": "https://developer.android.com/develop/ui/compose/accessibility/api-defaults#minimum-target-sizes"
},
{
"id": "compose-graphic-elements",
"title": "Jetpack Compose accessibility: graphic elements (contentDescription)",
"url": "https://developer.android.com/develop/ui/compose/accessibility/api-defaults#graphic-elements"
},
{
"id": "compose-interactive-elements",
"title": "Jetpack Compose accessibility: interactive elements (Button semantics)",
"url": "https://developer.android.com/develop/ui/compose/accessibility/api-defaults#interactive-elements"
}
]
},
"content": {
"guidelines": [
"Start with a verb: Save, Send invitation, Delete project.",
{
"text": "Sentence case, no trailing punctuation.",
"platforms": [
"web",
"android",
"desktop"
]
},
{
"text": "Title-style capitalization, as Apple's guidelines recommend: Add to Cart.",
"platforms": [
"ios"
]
},
"Keep labels to one to three words; never wrap.",
"Avoid generic labels like OK or Yes when a specific verb is possible.",
"With the error intent, name the consequence: Delete project, Remove member, never OK or Confirm."
]
},
"examples": [
{
"id": "accent-neutral",
"name": "Accent neutral",
"props": {
"emphasis": "accent",
"intent": "neutral",
"label": "Save changes"
}
},
{
"id": "subtle-neutral",
"name": "Subtle neutral",
"props": {
"emphasis": "subtle",
"intent": "neutral",
"label": "Cancel"
}
},
{
"id": "ghost-neutral",
"name": "Ghost neutral",
"props": {
"emphasis": "ghost",
"intent": "neutral",
"label": "Learn more"
}
},
{
"id": "accent-brand",
"name": "Accent brand",
"description": "The main path through a flow.",
"props": {
"emphasis": "accent",
"intent": "brand",
"label": "Continue",
"iconEnd": "arrow_forward"
}
},
{
"id": "subtle-brand",
"name": "Subtle brand",
"props": {
"emphasis": "subtle",
"intent": "brand",
"label": "Save draft"
}
},
{
"id": "accent-error",
"name": "Accent error",
"description": "Destructive confirmation: the label names the consequence.",
"props": {
"emphasis": "accent",
"intent": "error",
"label": "Delete project"
}
},
{
"id": "ghost-error-small",
"name": "Ghost error, small",
"description": "Destructive action repeated in a list.",
"props": {
"emphasis": "ghost",
"intent": "error",
"size": "small",
"label": "Remove",
"iconStart": "close"
}
},
{
"id": "subtle-neutral-xsmall",
"name": "Subtle neutral, extra small",
"props": {
"emphasis": "subtle",
"intent": "neutral",
"size": "xsmall",
"label": "Edit"
}
},
{
"id": "subtle-brand-small",
"name": "Subtle brand, small",
"props": {
"emphasis": "subtle",
"intent": "brand",
"size": "small",
"label": "Add row",
"iconStart": "add"
}
},
{
"id": "accent-brand-large",
"name": "Accent brand, large",
"props": {
"emphasis": "accent",
"intent": "brand",
"size": "large",
"label": "Get started"
}
},
{
"id": "accent-brand-disabled",
"name": "Accent brand, disabled",
"props": {
"emphasis": "accent",
"intent": "brand",
"label": "Save changes",
"disabled": true
}
}
],
"tokens": {
"source": "packages/tokens/src/components/button.tokens.json",
"prefix": "button"
},
"implementations": {
"web": {
"css": "packages/components/src/components/button/button.css",
"template": "packages/components/src/components/button/button.template.mjs",
"selector": ".ds-button",
"element": "button",
"attributes": {
"emphasis": "data-emphasis",
"intent": "data-intent",
"size": "data-size",
"disabled": "disabled"
},
"stateAttribute": "data-state",
"partSelectors": {
"container": ".ds-button",
"icon": ".ds-button__icon",
"label": ".ds-button__label"
}
}
},
"related": [
"icon-button",
"dropdown"
],
"decisions": [
{
"id": "default-size-medium",
"date": "2026-10-15",
"version": "0.22.0",
"area": "variant",
"status": "active",
"text": "Without a size, a button is medium (36 px), as the decision defaults says. The Figma prototype's Button set defaults to xsmall; the repository keeps medium. Its other defaults (accent, neutral, no icon) match the prototype.",
"rationale": "Every component with sizes in the system defaults to medium (text field, select, search field, number field, textarea, dropdown, icon button), so controls placed together without a size get the same size step; the prototype's xsmall default is also the top-left variant of its Button set and may come from the set's layout rather than from a choice.",
"tokens": [
"button.container.min-height.medium"
]
},
{
"id": "focus-ring-foundation-kept",
"date": "2026-10-15",
"version": "0.21.0",
"area": "accessibility",
"status": "active",
"text": "The button keeps the shared focus ring of the Focus ring foundation, at every size and emphasis: 2.4 px (focus.ring.width), color.focus.ring, 4 px from the button (focus.ring.offset). The Figma prototype draws 1.2 px in #497ede (global.border.base.brand.default; #1148a9 on most accent brand variants), 2 px from the button at extra small and small and 4 px above. A 2 px ring from the space scale is preferred for later, as a change of the whole system, tracked as a known issue.",
"rationale": "The ring belongs to the Focus ring foundation and is the same on every component; a 2 px ring and whole-pixel lengths are a global change to make later.",
"tokens": [
"button.container.outline-width.focus-visible",
"button.container.outline-color.focus-visible",
"button.container.outline-offset.focus-visible",
"focus.ring.*",
"color.focus.ring"
],
"refs": [
"wcag:1.4.11",
"wcag:2.4.13"
]
},
{
"id": "ghost-disabled-shared",
"date": "2026-10-15",
"version": "0.20.0",
"area": "color",
"status": "active",
"text": "A disabled ghost button takes the shared disabled fill and border, global.bg.state.disabled.default and global.border.state.disabled.default, like accent and subtle buttons, as in the Figma prototype: every disabled button looks the same whatever its emphasis.",
"tokens": [
"button.container.background.ghost.disabled",
"button.container.border-color.ghost.disabled"
]
},
{
"id": "ghost-border-one-step-behind",
"date": "2026-10-15",
"version": "0.20.0",
"area": "color",
"status": "active",
"text": "A ghost button has no border at rest; on hover and press it takes the subtle border one step behind, as its fill does (decision ghost-tint-one-step-behind) and as in the Figma prototype: the subtle resting border on hover, the subtle hover border on press (global.border.subtle.<family>.default / hover).",
"tokens": [
"button.container.border-color.ghost.*",
"global.border.subtle.*"
]
},
{
"id": "ghost-brand-label-on-base",
"date": "2026-10-08",
"version": "0.19.0",
"area": "color",
"status": "active",
"text": "At rest, a ghost brand button, which has no fill and sits on the surface, takes the brand text of the surface, global.text.on-base.brand.default, as in the Figma prototype; on hover and press, when its tint appears, it takes global.text.on-subtle.brand.default as before.",
"tokens": [
"button.label.color.ghost.brand.*",
"global.text.on-base.brand.default",
"global.text.on-subtle.brand.default"
]
},
{
"id": "error-label-figma-library",
"date": "2026-10-08",
"version": "0.19.0",
"area": "color",
"status": "active",
"text": "The label of subtle and ghost error buttons keeps the Figma library's error text, global.text.on-subtle.functional.error.default, in every state. The Figma prototype uses #940e11 (raw functional.error.700) on subtle error buttons and on ghost error buttons on hover and press; no text token carries it, and the system adds no semantic token for it for now. The label stays under 4.5:1 on the subtle error hover and press fills and the ghost error press fill in light mode, tracked as a known issue (decision figma-library-colors).",
"rationale": "No new semantic token for now.",
"tokens": [
"button.label.color.subtle.error.*",
"button.label.color.ghost.error.*",
"global.text.on-subtle.functional.error.default"
]
},
{
"id": "neutral-label-primary",
"date": "2026-10-08",
"version": "0.19.0",
"area": "color",
"status": "active",
"text": "The label of subtle and ghost neutral buttons is the primary text color, global.text.on-base.neutral.primary, in every state and in both modes, as in the Figma prototype; it was global.text.on-subtle.neutral.default, a softer grey.",
"tokens": [
"button.label.color.subtle.neutral.*",
"button.label.color.ghost.neutral.*",
"global.text.on-base.neutral.primary"
]
},
{
"id": "subtle-neutral-translucent",
"date": "2026-10-08",
"version": "0.18.0",
"area": "color",
"status": "active",
"text": "The subtle neutral fill stays translucent, global.bg.subtle.neutral.* (and ghost neutral borrows it one step behind on hover and press), with the global.border.subtle.neutral.* border. The Figma prototype uses opaque greys (#f7f7f7 / #eaeaea / #dddddd, closest to global.bg.base.neutral.2 / 3 / 4) and a #d5d5d5 border.",
"rationale": "A translucent fill darkens the surface it sits on, so the button keeps a visible fill on pages, cards, sidebars and images in both modes; an opaque grey equals the level 1 surface at rest.",
"tokens": [
"button.container.background.subtle.neutral.*",
"button.container.background.ghost.neutral.*",
"button.container.border-color.subtle.neutral.*"
]
},
{
"id": "border-follows-state",
"date": "2026-10-08",
"version": "0.17.0",
"area": "color",
"status": "active",
"text": "On hover and press, the border of accent and subtle buttons moves one step along the intent ramp with the fill, so it stays one step darker than the fill in every state. The Figma prototype keeps the resting border on most intents, and not on all of them.",
"rationale": "Every state keeps a visible edge, and one rule holds for every intent.",
"tokens": [
"button.container.border-color.accent.*",
"button.container.border-color.subtle.*"
]
},
{
"id": "label-letter-spacing-tight",
"date": "2026-10-08",
"version": "0.16.0",
"area": "typography",
"status": "active",
"text": "The label has the negative letter spacing of the Figma prototype: -2 % of the text size at extra small and small, -3 % at medium and large, carried by font.letter-spacing.tight.xs / sm / md / md (-0.24 / -0.28 / -0.48 / -0.48 px). The Button is the only component that uses it.",
"tokens": [
"button.label.typography.*",
"font.letter-spacing.tight.*"
]
},
{
"id": "label-line-height-1",
"date": "2026-10-08",
"version": "0.16.0",
"area": "typography",
"status": "active",
"text": "The label line height is 1 (font.line-height.compact), as in the Figma prototype, at every size. At the default text size the minimum height still sets the height; with larger text, a button without an icon grows less than with 1.2.",
"tokens": [
"button.label.typography.*",
"font.line-height.compact"
]
},
{
"id": "large-label-16",
"date": "2026-10-08",
"version": "0.16.0",
"area": "typography",
"status": "active",
"text": "The large button label is 16 px (body.size.md), as in the Figma prototype, the same size as medium: large differs from medium by its height, padding and corners. Label sizes are body.size.xs / sm / md / md, extra small to large.",
"tokens": [
"button.label.typography.large"
]
},
{
"id": "icon-optical-size-20",
"date": "2026-10-08",
"version": "0.15.0",
"area": "typography",
"status": "active",
"text": "Icons keep optical size 20 (font.icon.optical-size.20) at every size, including the 24 px icons of medium and large; the Figma prototype switches between 20 and 24.",
"rationale": "It eases implementation and keeps buttons consistent whatever their size.",
"tokens": [
"button.icon.optical-size",
"font.icon.optical-size.20"
]
},
{
"id": "block-padding-fits-icon",
"date": "2026-10-08",
"version": "0.15.0",
"area": "dimension",
"status": "active",
"supersedes": [
"block-padding-minimum"
],
"text": "The minimum block padding is space.2 / 3 / 5 / 8, extra small to large, so that the icon, the block padding and the border fit inside the minimum height: a button is 24 / 28 / 36 / 44 px high with or without an icon. As before, larger content grows the button and keeps that space above and below.",
"rationale": "With 20 px and 24 px icons, the previous 4 px and 6 px made small and medium buttons with an icon about 1 px taller than those without one, because the border takes room inside the minimum height.",
"tokens": [
"button.container.padding-block.*",
"button.container.min-height.*"
]
},
{
"id": "icon-size-from-prototype",
"date": "2026-10-08",
"version": "0.15.0",
"area": "dimension",
"status": "active",
"text": "The icon size follows the Figma prototype: size.2 / 3 / 4 / 4 (16 / 20 / 24 / 24 px), extra small to large.",
"rationale": "The prototype's rendering is the reference for the button.",
"tokens": [
"button.icon.size.*"
]
},
{
"id": "block-padding-minimum",
"date": "2026-10-08",
"version": "0.14.0",
"area": "dimension",
"status": "superseded",
"text": "The button has a minimum block padding of space.2 / 4 / 6 / 8, extra small to large. At the default text size the minimum height still sets the height and nothing moves; when the content outgrows it (larger text, a larger icon), the button grows and keeps that space above and below.",
"rationale": "An intermediate between the Figma prototype, whose block padding sets the height, and the repository, where only the minimum height did.",
"tokens": [
"button.container.padding-block.*",
"button.container.min-height.*"
]
},
{
"id": "gap-by-size",
"date": "2026-10-08",
"version": "0.13.0",
"area": "dimension",
"status": "active",
"text": "The gap between an icon and the label follows the size: space.2 / 2 / 4 / 4, extra small to large. It is smaller than in the Figma prototype (6 / 6 / 10 / 16 px).",
"rationale": "The prototype's gap is too large between the icon and the label.",
"tokens": [
"button.container.gap.*"
]
},
{
"id": "padding-next-to-icon",
"date": "2026-10-08",
"version": "0.12.0",
"area": "dimension",
"status": "active",
"text": "With an icon, the inline padding narrows on both sides, as in the Figma prototype: the side that holds an icon takes space.2 / 6 / 6 / 8 between the border and the icon, and the label side of a button with an icon on the other side only takes space.6 / 8 / 8 / 10, extra small to large. A button without an icon keeps space.6 / 8 / 12 / 16 on both sides.",
"rationale": "The prototype's rendering is the reference for the button.",
"tokens": [
"button.container.padding-inline.*"
]
},
{
"id": "heights-from-prototype",
"date": "2026-10-08",
"version": "0.11.0",
"area": "dimension",
"status": "active",
"text": "The minimum height follows the Figma prototype from small up and keeps 24 px at extra small: space.24 / 28 / 36 / 44, extra small to large. Every other size, spacing, radius, border, type, icon and motion value is unchanged and comes straight from the Figma library's scales and the design system's additions: the corner radius follows the size (corner-radius.2xs, 2 px, at extra small and small; corner-radius.xs, 4 px, at medium and large); the border is stroke-width.sm (0.6 px) at every size, emphasis and intent; padding space.6 / 8 / 12 / 16, icon size.2 / 2 / 3 / 4 and label body.size.xs / sm / md / lg at medium weight, extra small to large; icons are Material Symbols Outlined glyphs (font.icon.*: fill off, weight 300, grade 0, optical size 20); state transitions take duration.fast and easing.standard.",
"rationale": "The prototype's rendering is closer to the intended button. Extra small stays at 24 px so that every size meets the 24 x 24 CSS pixel minimum target by its own size, whatever the layout around it.",
"tokens": [
"button.container.*",
"button.label.typography.*",
"button.icon.*",
"corner-radius.*",
"stroke-width.sm",
"space.*",
"size.*",
"body.*",
"font.icon.*"
],
"refs": [
"wcag:2.5.8"
],
"supersedes": [
"figma-library-scales"
]
},
{
"id": "figma-library-colors",
"date": "2026-10-02",
"version": "0.10.0",
"area": "color",
"status": "active",
"text": "The button exists in every color mode of the system, light and dark, and takes the colors of the mode of its context. Every color aliases the Figma library's mapped roles as they are, in both modes: fills global.bg.<emphasis>.<family>.<state>, borders global.border.<emphasis>.<family>.<state>, label text global.text.on-<emphasis>.<family>, disabled global.*.state.disabled.default; a ghost button reads the subtle text colors. Where the Figma library's error colors fall under WCAG 2.2 AA, the gap is kept and tracked, not corrected in the button.",
"rationale": "The Figma library as it is, errors and limitations included, is the starting point the system evolves from.",
"tokens": [
"button.container.background.*",
"button.container.border-color.*",
"button.label.color.*",
"global.bg.*",
"global.border.*",
"global.text.*"
],
"refs": [
"wcag:1.4.3"
],
"supersedes": [
"color-modes",
"error-darker-than-prototype"
]
},
{
"id": "intent-families",
"date": "2026-10-02",
"version": "0.10.0",
"area": "variant",
"status": "active",
"text": "The intent property states the kind of action: neutral, brand or error. Each intent reads one color family of the mapped roles: neutral, brand and functional.error.",
"tokens": [
"global.bg.accent.*",
"global.bg.subtle.*",
"global.text.on-accent.*",
"global.text.on-subtle.*"
],
"supersedes": [
"intent-axis"
]
},
{
"id": "figma-library-scales",
"date": "2026-10-02",
"version": "0.10.0",
"area": "dimension",
"status": "superseded",
"text": "Sizes, spacing, radii, borders, type, icons and motion come straight from the Figma library's scales and the design system's additions, with the same values as before and no semantic layer in between: the corner radius follows the size (corner-radius.2xs, 2 px, at extra small and small; corner-radius.xs, 4 px, at medium and large); the border is stroke-width.sm (0.6 px) at every size, emphasis and intent; padding space.6 / 8 / 12 / 16, minimum height space.24 / 32 / 40 / 48, icon size.2 / 2 / 3 / 4 and label body.size.xs / sm / md / lg at medium weight, extra small to large; icons are Material Symbols Outlined glyphs (font.icon.*: fill off, weight 300, grade 0, optical size 20); state transitions take duration.fast and easing.standard.",
"tokens": [
"button.container.*",
"button.label.typography.*",
"button.icon.*",
"corner-radius.*",
"stroke-width.sm",
"space.*",
"size.*",
"body.*",
"font.icon.*"
],
"supersedes": [
"radius-by-size",
"border-width-hairline",
"icons-material-symbols"
]
},
{
"id": "color-modes",
"date": "2026-10-01",
"version": "0.9.0",
"area": "color",
"status": "superseded",
"text": "The button exists in every color mode of the system, light and dark, and takes the colors of the mode of its context. Its color decisions (emphasis, intent, state) are the same in every mode; only the semantic colors they alias change. Dark colors follow the prototype's dark mapping, with the error fill and border kept one step darker than the prototype, as in light mode.",
"tokens": [
"color.*",
"button.container.background.*",
"button.container.border-color.*",
"button.container.outline-color.*",
"button.label.color.*"
]
},
{
"id": "icons-material-symbols",
"date": "2026-10-01",
"version": "0.8.0",
"area": "typography",
"status": "superseded",
"text": "Icons are glyphs of Material Symbols, latest version, for every component: Outlined family with fill off, weight 300, grade 0 and optical size 20 px.",
"tokens": [
"icon.*",
"button.icon.*"
]
},
{
"id": "focus-ring-strong",
"date": "2026-10-01",
"version": "0.7.1",
"area": "accessibility",
"status": "active",
"text": "The focus ring is the strong focus of the stroke scale, 2.4 px, for every component, not the 1.2 px the prototype hints for focus rings.",
"rationale": "A 2.4 px ring keeps the 2 CSS px thickness of WCAG 2.4.13 Focus Appearance (AAA); 1.2 px meets WCAG 2.2 AA but not that criterion.",
"tokens": [
"focus.ring.width",
"button.container.outline-width.focus-visible"
],
"refs": [
"wcag:2.4.7",
"wcag:2.4.13"
]
},
{
"id": "radius-by-size",
"date": "2026-10-01",
"version": "0.7.0",
"area": "dimension",
"status": "superseded",
"text": "The corner radius follows the size: extra small and small buttons have 2 px corners, medium and large buttons 4 px corners.",
"tokens": [
"radius.control.*",
"button.container.border-radius.*"
]
},
{
"id": "border-width-hairline",
"date": "2026-10-01",
"version": "0.6.0",
"area": "dimension",
"status": "superseded",
"text": "The button border is 0.6 px at every size, emphasis and intent: the default component border of the stroke scale.",
"tokens": [
"border.width.control",
"button.container.border-width"
]
},
{
"date": "2026-10-01",
"version": "0.5.1",
"status": "superseded",
"id": "error-darker-than-prototype",
"area": "accessibility",
"text": "Error colors sit one step darker than the prototype mapping: the accent error fill uses error 600/700/800 (border 700/800/900) and error text on tinted fills uses error 700.",
"rationale": "Accessibility: with the prototype values, labels fall under 4.5:1 (4.47:1 on the accent error fill, 4.36:1 and 3.29:1 on the subtle error hover and press fills).",
"tokens": [
"color.background.accent.error.*",
"color.border.accent.error.*",
"color.text.on-subtle.error"
],
"refs": [
"wcag:1.4.3"
]
},
{
"date": "2026-10-01",
"version": "0.5.1",
"status": "active",
"id": "ghost-tint-one-step-behind",
"area": "color",
"text": "A ghost button takes the subtle tint one step behind: no fill at rest, the subtle rest fill on hover, the subtle hover fill on press.",
"tokens": [
"button.container.background.ghost.*"
]
},
{
"date": "2026-10-01",
"version": "0.5.1",
"status": "active",
"id": "defaults",
"area": "variant",
"text": "Without explicit options, a button is accent, neutral and medium."
},
{
"id": "ai-actions-own-look",
"date": "2026-10-01",
"version": "0.5.0",
"area": "variant",
"status": "active",
"text": "AI actions get their own look, defined later."
},
{
"id": "size-scale",
"date": "2026-10-01",
"version": "0.5.0",
"area": "size",
"status": "active",
"text": "The size property offers four sizes: xsmall, small, medium and large.",
"tokens": [
"button.container.min-height.*",
"button.container.padding-inline.*",
"button.label.typography.*",
"button.icon.size.*"
]
},
{
"id": "intent-axis",
"date": "2026-10-01",
"version": "0.5.0",
"area": "variant",
"status": "superseded",
"text": "The intent property states the kind of action: neutral, brand or error.",
"tokens": [
"color.background.accent.*",
"color.background.subtle.*",
"color.text.on-accent.*",
"color.text.on-subtle.*"
]
},
{
"id": "emphasis-axis",
"date": "2026-10-01",
"version": "0.5.0",
"area": "variant",
"status": "active",
"text": "The emphasis property sets the visual weight: accent, subtle or ghost.",
"tokens": [
"button.container.background.*",
"button.container.border-color.*",
"button.label.color.*"
]
}
],
"changelog": [
{
"version": "0.22.1",
"date": "2026-10-15",
"note": "Fix: the focus ring shows on keyboard focus. The ring rule was wrapped in :where(), so the :focus reset (outline: none) outranked it and a focused button drew no ring; it now uses :is() and wins by source order. The forced focus-visible state was not affected. No token or decision changes."
},
{
"version": "0.22.0",
"date": "2026-10-15",
"note": "Decision default-size-medium: a button without a size stays medium, rather than the Figma prototype's xsmall default; the other defaults (accent, neutral, no icon) already match it. No token or rendering change."
},
{
"version": "0.21.0",
"date": "2026-10-15",
"note": "Decision focus-ring-foundation-kept: the button keeps the foundation's focus ring (2.4 px, color.focus.ring, 4 px offset at every size) rather than the Figma prototype's 1.2 px #497ede ring with a 2 px offset at the two smaller sizes. Both pass 3:1 against every surface (shared ring 5.45:1 or more in light, 9.59:1 or more in dark); only the shared ring meets WCAG 2.4.13. A 2 px ring from the space scale is preferred for later, tracked as a known issue. No token or rendering change."
},
{
"version": "0.20.0",
"date": "2026-10-15",
"note": "Decisions ghost-border-one-step-behind and ghost-disabled-shared, as in the Figma prototype. Ghost buttons gain the subtle border one step behind on hover and press (neutral #DFDBDC / #D7D3D4, brand #DDE6F5 / #BFD1F1, error #FCCDC6 / #FAA79D in light; Figma has #d5d5d5 / #dddddd for neutral); none at rest. A disabled ghost button takes the shared disabled fill and border. Existing semantic tokens only. Fills and labels unchanged, so label contrast is unchanged; the borders need none (WCAG 1.4.11, the label identifies the button) and disabled buttons are exempt (WCAG 1.4.3)."
},
{
"version": "0.19.0",
"date": "2026-10-08",
"note": "Decisions neutral-label-primary, ghost-brand-label-on-base and error-label-figma-library: label colors of subtle and ghost buttons, compared with the Figma prototype. Neutral (subtle and ghost, every state): global.text.on-base.neutral.primary instead of global.text.on-subtle.neutral.default, as in Figma; contrast on the page and on a card, lowest per state, light 15.99:1 or more (was 10.80), dark 5.55:1 or more (was 6.46). Ghost brand at rest: global.text.on-base.brand.default, as in Figma (#175DD6 in light, 5.45:1 or more; was 7.71); dark unchanged. Error: unchanged, global.text.on-subtle.functional.error.default; Figma's #940e11 would need a new semantic token, not added for now. Known exceptions unchanged: the accent error fill at rest (4.46:1, both modes), the subtle error fill on hover and press (4.36 and 3.29:1) and the ghost error press (4.36:1), in light mode."
},
{
"version": "0.18.0",
"date": "2026-10-08",
"note": "Decision subtle-neutral-translucent: the subtle neutral fill stays translucent (global.bg.subtle.neutral.*), ghost neutral keeps borrowing it on hover and press, and the border stays global.border.subtle.neutral.*. The Figma prototype's opaque greys (#f7f7f7 / #eaeaea / #dddddd, closest to global.bg.base.neutral.2 / 3 / 4) equal the level 1 surface at rest, in light and dark mode, so the button would lose its fill on cards and sidebars. No token or rendering change; contrast unchanged. Docs: why the neutral tint is see-through."
},
{
"version": "0.17.0",
"date": "2026-10-08",
"note": "Decision border-follows-state: the border keeps moving with the fill on hover and press (one step darker than the fill in every state), the rule the button already had; the Figma prototype keeps the resting border on accent buttons and subtle brand, moves it on hover only for subtle error and on press only for subtle neutral. No token or rendering change. The hover and active state descriptions now say the label keeps its color (they said it moved, which no token did)."
},
{
"version": "0.16.0",
"date": "2026-10-08",
"note": "Decisions large-label-16, label-line-height-1 and label-letter-spacing-tight: the label typography follows the Figma prototype. The large label is 16 px (body.size.md) instead of 20 px (body.size.lg), the same as medium; whether a size between 16 and 20 px is needed is tracked as a known issue. The line height is 1 (new addition font.line-height.compact) instead of 1.2, and the letter spacing -2 % at extra small and small, -3 % at medium and large (new additions font.letter-spacing.tight.xs / sm / md: -0.24 / -0.28 / -0.48 px) instead of 0. Heights unchanged at the default text size (24 / 28 / 36 / 44 px); the large button is about 12 px narrower, labels 1 to 3 px narrower. Colors unchanged, so contrast is unchanged."
},
{
"version": "0.15.0",
"date": "2026-10-08",
"note": "Decisions icon-size-from-prototype, block-padding-fits-icon (supersedes block-padding-minimum) and icon-optical-size-20. Icons are 16 / 20 / 24 / 24 px (size.2 / 3 / 4 / 4), extra small to large, as in the Figma prototype; they were 16 / 16 / 20 / 24 px. The minimum block padding goes from 2 / 4 / 6 / 8 px to 2 / 3 / 5 / 8 px (space.2 / 3 / 5 / 8) so that small and medium buttons with an icon stay 28 and 36 px high: in CSS the 0.6 px border takes room inside the minimum height, in Figma it does not. Icons keep optical size 20 at every size, where the prototype switches between 20 and 24. Heights unchanged (24 / 28 / 36 / 44 px, measured with and without icons). Colors unchanged, so contrast is unchanged."
},
{
"version": "0.14.0",
"date": "2026-10-08",
"note": "Decision block-padding-minimum: a block padding of 2 / 4 / 6 / 8 px (space.2 / 4 / 6 / 8), extra small to large, instead of 0. The button keeps its 24 / 28 / 36 / 44 px at the default text size; with larger content it grows and keeps that space above and below, where the content used to reach the edges. The Figma prototype has 4 / 7 / 10 / 14 px without an icon and 2 / 4 / 6 / 10 px with one, and its padding sets the height. New component tokens button.container.padding-block.*. Colors unchanged, so contrast is unchanged."
},
{
"version": "0.13.0",
"date": "2026-10-08",
"note": "Decision gap-by-size: the gap between an icon and the label is 2 / 2 / 4 / 4 px (space.2 / 2 / 4 / 4), extra small to large, instead of 4 px at every size; the Figma prototype has 6 / 6 / 10 / 16 px, found too large. button.container.gap becomes one token per size. Colors unchanged, so contrast is unchanged."
},
{
"version": "0.12.0",
"date": "2026-10-08",
"note": "Decision padding-next-to-icon: a button with an icon has a narrower inline padding, as in the Figma prototype. Icon side 2 / 6 / 6 / 8 px (space.2 / 6 / 6 / 8), label side 6 / 8 / 8 / 10 px (space.6 / 8 / 8 / 10), extra small to large; without an icon the padding stays 6 / 8 / 12 / 16 px. New component tokens button.container.padding-inline.icon-side.* and button.container.padding-inline.label-side.*; the CSS picks them with :has() on the icon part and its data-position. Colors unchanged, so contrast is unchanged."
},
{
"version": "0.11.0",
"date": "2026-10-08",
"note": "Decision heights-from-prototype: the small, medium and large buttons are 4 px shorter, 28 / 36 / 44 px instead of 32 / 40 / 48 px (space.28 / 36 / 44), as in the Figma prototype. Extra small stays at 24 px, the WCAG 2.2 2.5.8 minimum target, where the prototype has 20 px. Only button.container.min-height.* changes; colors, padding, type and CSS are unchanged, so contrast is unchanged. On iOS the large button now reaches the 44 pt hit region Apple recommends; on Android every size is now under the 48 dp touch target."
},
{
"version": "0.10.0",
"date": "2026-10-02",
"note": "Decisions figma-library-colors, intent-families and figma-library-scales: the tokens are recreated from the Figma library (Color foundation 3.0.0). Component tokens keep their paths and the CSS is unchanged; every color now aliases a Figma library mapped role (global.bg / global.text / global.border, error being functional.error) instead of the repository's color.* roles. Visible changes: the error colors follow the Figma library (accent error fill error 500 / 600 / 700 instead of 600 / 700 / 800, border 600 / 700 / 800 instead of 700 / 800 / 900, error text on tinted fills error 600 instead of 700), and dark mode is the Figma library's dark mapping. Label contrast over the level 0 and level 1 surfaces, every platform: light 5.38:1 or more, dark 6.05:1 or more, except the known failures tracked as a known issue: accent error at rest 4.46:1 (both modes), subtle error hover 4.35:1 and press 3.28:1, ghost error press 4.35:1 (light). Focus ring light 5.46:1 or more, dark 9.61:1 or more on every level. Sizes, spacing, radii, border, type, icons and motion now alias the Figma library's scales and the icon font, line height, letter spacing and motion additions directly, instead of the repository's invented semantic tokens (control.*, space.inset.*, radius.control.*, border.width.*, text.control.*, icon.*, motion.control.*): same values, no visual change. iOS and Android lose the overlays that raised the extra small, small and medium heights to 44 pt and 48 dp."
},
{
"version": "0.9.1",
"date": "2026-10-01",
"note": "Lists Dropdown as related: a button opening a menu of actions is that component, built on this one. The template accepts an attrs prop (extra HTML attributes, a class appended to ds-button) so a component composing Button wires it without copying its markup. No visual change: tokens and CSS unchanged."
},
{
"version": "0.9.0",
"date": "2026-10-01",
"note": "Decision color-modes. The system gains a dark mode (tokens/modes/dark/, the prototype's Colors / Mapped dark) next to the default light mode. Component tokens, CSS and template unchanged: the button reads the same variables, which resolve again in every data-theme context (light, dark, auto) on the web and in the per-mode trees of the other platforms. The error fill and border keep their light values in dark mode (one step darker than the prototype, whose error 25 on error 500 is 4.47:1). Dark contrast, every emphasis x intent x enabled state on both surfaces: label 6.05:1 minimum (accent error, rest), focus ring 11.73:1 or more against the surfaces. Light unchanged: label 4.76:1 minimum, focus ring 5.47:1 or more."
},
{
"version": "0.8.1",
"date": "2026-10-01",
"note": "Lists Icon button as related: icon-only actions are that component. No visual change: tokens, CSS and template unchanged."
},
{
"version": "0.8.0",
"date": "2026-10-01",
"note": "Decision icons-material-symbols. The placeholder stroke SVG icons become Material Symbols Outlined glyphs (Google Fonts, fill 0, weight 300, grade 0, optical size 20), written as ligature names: arrow_forward, close and add replace arrow-right, x and plus in the examples. New raw font.icon.* and semantic icon.* tokens; button.icon gains font-family, font-weight, fill, grade and optical-size; icon sizes unchanged. The glyph keeps the label color, so contrast is unchanged (label 4.76:1 minimum, focus ring 5.47:1 or more)."
},
{
"version": "0.7.1",
"date": "2026-10-01",
"note": "Decision focus-ring-strong confirms the 2.4 px focus ring (stroke-width.lg) for every component and adds a guideline citing WCAG 2.4.13 Focus Appearance (AAA): ring 5.47:1 or more against the surface. No visual change: tokens, CSS and template unchanged."
},
{
"version": "0.7.0",
"date": "2026-10-01",
"note": "Decision radius-by-size. The corner radius becomes a size token: xsmall and small 2 px (corner-radius.2xs), medium and large 4 px (corner-radius.xs); was 8 px at every size. Tighter than the prototype radius hints (4 px for a small button, 8 px for a button). New semantic radius.control.{xs,sm,md,lg} replaces radius.control; CSS reads the radius through --_border-radius set by each size. No color change: contrast unchanged."
},
{
"version": "0.6.0",
"date": "2026-10-01",
"note": "Decision border-width-hairline. The control border becomes 0.6 px (stroke-width.sm, the prototype's default border for cards, inputs and components; was 1.2 px). Browsers snap it to one device pixel. The border does not identify the button, so WCAG 1.4.11 asks no contrast of it; label contrast (4.76:1 minimum) and focus ring (5.47:1 or more) unchanged. CSS and template unchanged."
},
{
"version": "0.5.2",
"date": "2026-10-01",
"note": "Example names and ids state the emphasis and intent they combine (Accent neutral, Subtle brand, Ghost error, small…) instead of a single axis. No visual change: tokens, CSS and template unchanged."
},
{
"version": "0.5.1",
"date": "2026-10-01",
"note": "Decisions defaults, ghost-tint-one-step-behind and error-darker-than-prototype confirm what 0.5.0 implemented. No visual change: spec defaults, tokens, CSS and template unchanged."
},
{
"version": "0.5.0",
"date": "2026-10-01",
"note": "Decisions emphasis-axis, intent-axis, size-scale, ai-actions-own-look. The variant property becomes emphasis (accent, subtle, ghost) and a new intent property (neutral, brand, error) combines with it; sizes become xsmall, small, medium, large (xsmall new, 24 px; 44 pt on iOS, 48 dp on Android). Colors follow the prototype mapping (bg.accent / bg.subtle, text.on-accent / text.on-subtle, border.accent / border.subtle, state.disabled) except error, one step darker so labels pass WCAG 1.4.3. Ghost hover and press borrow the subtle rest and hover fills. Label contrast, every emphasis x intent x enabled state on both surfaces: 4.76:1 minimum (subtle error, pressed), focus ring 5.47:1 or more."
},
{
"version": "0.4.0",
"date": "2026-10-01",
"note": "New raw tokens from the prototype export: brand blue and ash neutral palette, 1.2 px borders and 2.4 px focus ring (stroke-width scale), large label at 20 px (was 18). Label contrast 5.7:1 or more and focus ring 5.8:1 against the surface. Component tokens, CSS and template unchanged."
},
{
"version": "0.3.0",
"date": "2026-09-29",
"note": "Platform scoping. States say which inputs produce them (hover: pointer; focus-visible: keyboard). Accessibility statements are universal or scoped to web, iOS or Android, the platform ones cited from Apple HIG and Android developers (44 pt and 48 dp targets, press state, Reduce Motion, decorative icons). Label capitalization rule per platform. iOS and Android token overlays raise small and medium heights to the platform target and set the system font; component tokens unchanged."
},
{
"version": "0.2.0",
"date": "2026-09-29",
"note": "Accessibility contract: every statement cites trusted sources (WCAG 2.2, APG, WAI-ARIA, AccName, HTML, MDN). Icon part marked decorative. Space key behaviour aligned with APG (the on-key-up claim had no source)."
},
{
"version": "0.1.0",
"date": "2026-09-29",
"note": "Initial draft with primary, secondary and ghost variants in three sizes."
}
]
}
Follows the Color foundation (90 tokens), the Focus ring foundation (3 tokens), the Iconography foundation (5 tokens), the Motion foundation (2 tokens), the Shape foundation (5 tokens), the Spacing and sizing foundation (28 tokens), the Typography foundation (4 tokens).
Anatomy
| Part | Role | Required | Description | Web selector |
|---|---|---|---|---|
container |
container |
yes | Interactive root. Receives focus, carries background, border, size and the state styling. | .ds-button |
icon (repeatable) (decorative) |
icon |
no | Optional glyph of the icon font before (leading) or after (trailing) the label; at most one on each side. Inherits the label color. | .ds-button__icon |
label |
label |
yes | Text describing the action. Provides the accessible name. | .ds-button__label |
Hover or focus a row to outline the part in the rendering above; hover the rendering to find its row.
Properties
| Property | Type | Options / default | Binds to / affects | Description |
|---|---|---|---|---|
emphasis |
enum |
|
affects
containerlabelweb:
data-emphasis |
Visual weight of the button, from strongest to weakest. Use one accent button per view. |
intent |
enum |
|
affects
containerlabelweb:
data-intent |
Kind of action, independent of emphasis. Every intent exists at every emphasis. |
size |
enum |
|
affects
containerlabeliconweb:
data-size |
Control height, inline padding, corner radius, text size and icon size scale together. |
label required |
string | — |
binds
label |
Verb-first, no trailing punctuation; capitalization follows the content rules of the platform. |
iconStart |
icon | — |
binds
icon |
Leading icon, reinforces the verb. |
iconEnd |
icon | — |
binds
icon |
Trailing icon, typically directional (arrow, chevron). |
disabled |
boolean |
false
|
web:
disabled |
Prevents activation; hover and active feedback no longer apply. Prefer keeping actions enabled and validating on activation. |
States
| State | Trigger | Applies to | Description |
|---|---|---|---|
default |
interaction | every platform and input | Resting state. |
hover |
interaction | input: pointer | Pointer over the container. Needs a pointing device: touch has no hover. Fill and border move one step along the intent ramp; the label keeps its color. |
active |
interaction | every platform and input | Pointer, finger or key pressed down: the press state. Fill and border move a second step along the intent ramp. |
focus-visible |
interaction | input: keyboard | Keyboard focus. Shows the shared focus ring outside the border; pointer focus shows none. |
disabled |
property (disabled) |
every platform and input | Non-interactive. Colors drop to the shared disabled palette whatever the emphasis and intent; hover and active do not apply. |
Layout
direction horizontalalign centerjustify centerwrap falsesizing hug
- Parts sit on one row separated by container.gap, which follows the size option.
- Inline padding, minimum height and corner radius come from the size option; the block padding is a minimum: at the default text size the minimum height sets the height and the content centers vertically; larger content grows the button and keeps the block padding. With an icon, the side that holds it takes the icon-side padding and the label side of a one-icon button the label-side padding.
- Width hugs the content; a layout may stretch it (full width) without changing the component.
Accessibility
Target: WCAG 2.2 level AA. Every statement cites the trusted source it comes from.
ARIA role button.
Accessible name: Computed from the label text. Decorative icons are hidden from assistive technology and add nothing to the name. APG: Button pattern, roles, states and propertiesAccName 1.2: name from content (step 2F)AccName 1.2: hidden nodes are excluded (step 2A)
| Key | Action | Platforms | Sources |
|---|---|---|---|
| Tab | Moves focus to the button. A disabled button is not focusable and is skipped. | Web | WCAG 2.1.1 Keyboard (A)HTML: focusable areaHTML: disabled form controls |
| Enter | Activates the button. | Web | APG: Button pattern, keyboard interaction |
| Space | Activates the button. | Web | APG: Button pattern, keyboard interaction |
Every platform
- Expose the button role, its name and its disabled state through the platform's native button control rather than a custom view, so assistive technology receives them without extra work. WCAG 4.1.2 Name, Role, Value (A)Apple HIG: Buttons, style (system buttons carry accessibility support)Jetpack Compose accessibility: interactive elements (Button semantics)
- Use a button for actions and a link for navigation: the role must match the function the control provides. APG: Button patternApple HIG: Buttons
- Never rely on color alone to convey emphasis or intent: the label carries the meaning, and an error-intent button names the consequence of the action. WCAG 1.4.1 Use of Color (A)Apple HIG: Accessibility, vision (contrast, color alone)
- Label text contrasts at least 4.5:1 with the container background in every color mode, emphasis, intent and enabled state, on the default and subtle surfaces. Disabled buttons are inactive and exempt. Known exceptions, from the Figma library's error colors: the accent error fill at rest (4.46:1, both modes), the subtle error fill on hover and press and the ghost error press (4.35:1 and 3.28:1, light mode); tracked as a known issue. WCAG 1.4.3 Contrast (Minimum) (AA)Apple HIG: Accessibility, vision (contrast, color alone)
- The border is not what identifies the button: its label does, so the light borders of the subtle and ghost emphases, and the absent border of a ghost button at rest, need no 3:1 boundary contrast. WCAG 1.4.11 Non-text Contrast (AA)
- A visible focus indicator appears when the button receives keyboard focus, and contrasts at least 3:1 with the adjacent colors in every color mode. WCAG 2.4.7 Focus Visible (AA)WCAG 1.4.11 Non-text Contrast (AA)
- The focus ring is 2.4 px thick and drawn outside the border, so it covers at least the area of a 2 CSS pixel thick perimeter of the button at 3:1 or more, as WCAG 2.4.13 Focus Appearance asks (AAA, adopted as extra quality). WCAG 2.4.13 Focus Appearance (AAA)
- Every size is a pointer target of at least 24 x 24 CSS pixels through its minimum height and inline padding; extra small sits exactly at that minimum. WCAG 2.5.8 Target Size (Minimum) (AA)
- The visible label is the accessible name, so speech-input users can activate the button by saying what they see. WCAG 2.5.3 Label in Name (A)
- Icons are decorative and hidden from assistive technology. The label alone must make sense. WCAG 1.1.1 Non-text Content (A)
- Color transitions are removed when the user asks for reduced motion. WCAG 2.3.3 Animation from Interactions (AAA)
Web
- Use the native button element: it exposes the role, the name and the disabled state without ARIA. Do not add role="button" to it. ARIA in HTML: button elementHTML-AAM: button element
- The focus ring is drawn with :focus-visible, so pointer focus shows no ring. MDN: :focus-visible
- Hide each icon with aria-hidden="true" so it adds nothing to the accessible name: an icon-font glyph is written as its ligature name, which would otherwise be read with the label. WAI-ARIA 1.2: aria-hiddenAccName 1.2: hidden nodes are excluded (step 2A)
- The native disabled attribute removes the button from the focus order. When people need to discover why an action is unavailable, keep the button enabled and explain on activation, or keep it focusable with aria-disabled. HTML: disabled form controlsAPG: Focusability of disabled controls
- The transition is switched off under prefers-reduced-motion: reduce. MDN: prefers-reduced-motion
iOS
- Apple recommends a hit region of at least 44 x 44 pt. The extra small, small and medium buttons (24, 28 and 36 pt high) fall under it on iOS today: the Figma library has no platform heights. Apple HIG: Buttons, best practices (hit region, press state)Apple HIG: Accessibility, mobility (control sizes)
- Always include a press state: without one a custom button feels unresponsive. The active state is that press state. Apple HIG: Buttons, best practices (hit region, press state)
- When the Reduce Motion setting is on, reduce automatic and repetitive animations. Apple HIG: Accessibility, cognitive (Reduce Motion)
Android
- Android recommends a touch target of at least 48 x 48 dp. Every size (24, 28, 36 and 44 dp high) falls under it on Android today: the Figma library has no platform heights. Android accessibility: use large, simple controlsJetpack Compose accessibility: minimum touch target sizes
- Pass null as the icon's contentDescription, or hide it with hideFromAccessibility, so it adds nothing to the button's merged description. Jetpack Compose accessibility: graphic elements (contentDescription)Android accessibility: describe each UI element
WCAG success criteria
| Criterion | Level | Understanding |
|---|---|---|
| 1.1.1 Non-text Content | A | Understanding 1.1.1 |
| 1.4.1 Use of Color | A | Understanding 1.4.1 |
| 1.4.3 Contrast (Minimum) | AA | Understanding 1.4.3 |
| 1.4.11 Non-text Contrast | AA | Understanding 1.4.11 |
| 2.1.1 Keyboard | A | Understanding 2.1.1 |
| 2.3.3 Animation from Interactions | AAA (beyond the AA target) | Understanding 2.3.3 |
| 2.4.7 Focus Visible | AA | Understanding 2.4.7 |
| 2.4.13 Focus Appearance | AAA (beyond the AA target) | Understanding 2.4.13 |
| 2.5.3 Label in Name | A | Understanding 2.5.3 |
| 2.5.8 Target Size (Minimum) | AA | Understanding 2.5.8 |
| 4.1.2 Name, Role, Value | A | Understanding 4.1.2 |
References
- APG: Button pattern W3C WAI-ARIA Authoring Practices Guide (APG) · guidance
- APG: Button pattern, keyboard interaction W3C WAI-ARIA Authoring Practices Guide (APG) · guidance
- APG: Button pattern, roles, states and properties W3C WAI-ARIA Authoring Practices Guide (APG) · guidance
- APG: Focusability of disabled controls W3C WAI-ARIA Authoring Practices Guide (APG) · guidance
- ARIA in HTML: button element W3C ARIA in HTML · standard
- HTML-AAM: button element W3C HTML Accessibility API Mappings (HTML-AAM) · standard
- WAI-ARIA 1.2: aria-hidden W3C Accessible Rich Internet Applications (WAI-ARIA) · standard
- AccName 1.2: name from content (step 2F) W3C Accessible Name and Description Computation (AccName) · standard
- AccName 1.2: hidden nodes are excluded (step 2A) W3C Accessible Name and Description Computation (AccName) · standard
- HTML: disabled form controls WHATWG HTML Living Standard · standard
- HTML: focusable area WHATWG HTML Living Standard · standard
- MDN: :focus-visible MDN Web Docs · reference
- MDN: prefers-reduced-motion MDN Web Docs · reference
- Apple HIG: Buttons Apple Human Interface Guidelines and accessibility documentation · platform
- Apple HIG: Buttons, best practices (hit region, press state) Apple Human Interface Guidelines and accessibility documentation · platform
- Apple HIG: Buttons, style (system buttons carry accessibility support) Apple Human Interface Guidelines and accessibility documentation · platform
- Apple HIG: Accessibility, vision (contrast, color alone) Apple Human Interface Guidelines and accessibility documentation · platform
- Apple HIG: Accessibility, mobility (control sizes) Apple Human Interface Guidelines and accessibility documentation · platform
- Apple HIG: Accessibility, cognitive (Reduce Motion) Apple Human Interface Guidelines and accessibility documentation · platform
- Android accessibility: use large, simple controls Android accessibility documentation · platform
- Android accessibility: describe each UI element Android accessibility documentation · platform
- Jetpack Compose accessibility: minimum touch target sizes Android accessibility documentation · platform
- Jetpack Compose accessibility: graphic elements (contentDescription) Android accessibility documentation · platform
- Jetpack Compose accessibility: interactive elements (Button semantics) Android accessibility documentation · platform
Content
- Start with a verb: Save, Send invitation, Delete project.
- Sentence case, no trailing punctuation. Web, Android, Desktop
- Title-style capitalization, as Apple's guidelines recommend: Add to Cart. iOS
- Keep labels to one to three words; never wrap.
- Avoid generic labels like OK or Yes when a specific verb is possible.
- With the error intent, name the consequence: Delete project, Remove member, never OK or Confirm.
Changelog
0.22.12026-10-15 — Fix: the focus ring shows on keyboard focus. The ring rule was wrapped in :where(), so the :focus reset (outline: none) outranked it and a focused button drew no ring; it now uses :is() and wins by source order. The forced focus-visible state was not affected. No token or decision changes.0.22.02026-10-15 — Decision default-size-medium: a button without a size stays medium, rather than the Figma prototype's xsmall default; the other defaults (accent, neutral, no icon) already match it. No token or rendering change.0.21.02026-10-15 — Decision focus-ring-foundation-kept: the button keeps the foundation's focus ring (2.4 px, color.focus.ring, 4 px offset at every size) rather than the Figma prototype's 1.2 px #497ede ring with a 2 px offset at the two smaller sizes. Both pass 3:1 against every surface (shared ring 5.45:1 or more in light, 9.59:1 or more in dark); only the shared ring meets WCAG 2.4.13. A 2 px ring from the space scale is preferred for later, tracked as a known issue. No token or rendering change.0.20.02026-10-15 — Decisions ghost-border-one-step-behind and ghost-disabled-shared, as in the Figma prototype. Ghost buttons gain the subtle border one step behind on hover and press (neutral #DFDBDC / #D7D3D4, brand #DDE6F5 / #BFD1F1, error #FCCDC6 / #FAA79D in light; Figma has #d5d5d5 / #dddddd for neutral); none at rest. A disabled ghost button takes the shared disabled fill and border. Existing semantic tokens only. Fills and labels unchanged, so label contrast is unchanged; the borders need none (WCAG 1.4.11, the label identifies the button) and disabled buttons are exempt (WCAG 1.4.3).0.19.02026-10-08 — Decisions neutral-label-primary, ghost-brand-label-on-base and error-label-figma-library: label colors of subtle and ghost buttons, compared with the Figma prototype. Neutral (subtle and ghost, every state): global.text.on-base.neutral.primary instead of global.text.on-subtle.neutral.default, as in Figma; contrast on the page and on a card, lowest per state, light 15.99:1 or more (was 10.80), dark 5.55:1 or more (was 6.46). Ghost brand at rest: global.text.on-base.brand.default, as in Figma (#175DD6 in light, 5.45:1 or more; was 7.71); dark unchanged. Error: unchanged, global.text.on-subtle.functional.error.default; Figma's #940e11 would need a new semantic token, not added for now. Known exceptions unchanged: the accent error fill at rest (4.46:1, both modes), the subtle error fill on hover and press (4.36 and 3.29:1) and the ghost error press (4.36:1), in light mode.0.18.02026-10-08 — Decision subtle-neutral-translucent: the subtle neutral fill stays translucent (global.bg.subtle.neutral.*), ghost neutral keeps borrowing it on hover and press, and the border stays global.border.subtle.neutral.*. The Figma prototype's opaque greys (#f7f7f7 / #eaeaea / #dddddd, closest to global.bg.base.neutral.2 / 3 / 4) equal the level 1 surface at rest, in light and dark mode, so the button would lose its fill on cards and sidebars. No token or rendering change; contrast unchanged. Docs: why the neutral tint is see-through.0.17.02026-10-08 — Decision border-follows-state: the border keeps moving with the fill on hover and press (one step darker than the fill in every state), the rule the button already had; the Figma prototype keeps the resting border on accent buttons and subtle brand, moves it on hover only for subtle error and on press only for subtle neutral. No token or rendering change. The hover and active state descriptions now say the label keeps its color (they said it moved, which no token did).0.16.02026-10-08 — Decisions large-label-16, label-line-height-1 and label-letter-spacing-tight: the label typography follows the Figma prototype. The large label is 16 px (body.size.md) instead of 20 px (body.size.lg), the same as medium; whether a size between 16 and 20 px is needed is tracked as a known issue. The line height is 1 (new addition font.line-height.compact) instead of 1.2, and the letter spacing -2 % at extra small and small, -3 % at medium and large (new additions font.letter-spacing.tight.xs / sm / md: -0.24 / -0.28 / -0.48 px) instead of 0. Heights unchanged at the default text size (24 / 28 / 36 / 44 px); the large button is about 12 px narrower, labels 1 to 3 px narrower. Colors unchanged, so contrast is unchanged.0.15.02026-10-08 — Decisions icon-size-from-prototype, block-padding-fits-icon (supersedes block-padding-minimum) and icon-optical-size-20. Icons are 16 / 20 / 24 / 24 px (size.2 / 3 / 4 / 4), extra small to large, as in the Figma prototype; they were 16 / 16 / 20 / 24 px. The minimum block padding goes from 2 / 4 / 6 / 8 px to 2 / 3 / 5 / 8 px (space.2 / 3 / 5 / 8) so that small and medium buttons with an icon stay 28 and 36 px high: in CSS the 0.6 px border takes room inside the minimum height, in Figma it does not. Icons keep optical size 20 at every size, where the prototype switches between 20 and 24. Heights unchanged (24 / 28 / 36 / 44 px, measured with and without icons). Colors unchanged, so contrast is unchanged.0.14.02026-10-08 — Decision block-padding-minimum: a block padding of 2 / 4 / 6 / 8 px (space.2 / 4 / 6 / 8), extra small to large, instead of 0. The button keeps its 24 / 28 / 36 / 44 px at the default text size; with larger content it grows and keeps that space above and below, where the content used to reach the edges. The Figma prototype has 4 / 7 / 10 / 14 px without an icon and 2 / 4 / 6 / 10 px with one, and its padding sets the height. New component tokens button.container.padding-block.*. Colors unchanged, so contrast is unchanged.0.13.02026-10-08 — Decision gap-by-size: the gap between an icon and the label is 2 / 2 / 4 / 4 px (space.2 / 2 / 4 / 4), extra small to large, instead of 4 px at every size; the Figma prototype has 6 / 6 / 10 / 16 px, found too large. button.container.gap becomes one token per size. Colors unchanged, so contrast is unchanged.0.12.02026-10-08 — Decision padding-next-to-icon: a button with an icon has a narrower inline padding, as in the Figma prototype. Icon side 2 / 6 / 6 / 8 px (space.2 / 6 / 6 / 8), label side 6 / 8 / 8 / 10 px (space.6 / 8 / 8 / 10), extra small to large; without an icon the padding stays 6 / 8 / 12 / 16 px. New component tokens button.container.padding-inline.icon-side.* and button.container.padding-inline.label-side.*; the CSS picks them with :has() on the icon part and its data-position. Colors unchanged, so contrast is unchanged.0.11.02026-10-08 — Decision heights-from-prototype: the small, medium and large buttons are 4 px shorter, 28 / 36 / 44 px instead of 32 / 40 / 48 px (space.28 / 36 / 44), as in the Figma prototype. Extra small stays at 24 px, the WCAG 2.2 2.5.8 minimum target, where the prototype has 20 px. Only button.container.min-height.* changes; colors, padding, type and CSS are unchanged, so contrast is unchanged. On iOS the large button now reaches the 44 pt hit region Apple recommends; on Android every size is now under the 48 dp touch target.0.10.02026-10-02 — Decisions figma-library-colors, intent-families and figma-library-scales: the tokens are recreated from the Figma library (Color foundation 3.0.0). Component tokens keep their paths and the CSS is unchanged; every color now aliases a Figma library mapped role (global.bg / global.text / global.border, error being functional.error) instead of the repository's color.* roles. Visible changes: the error colors follow the Figma library (accent error fill error 500 / 600 / 700 instead of 600 / 700 / 800, border 600 / 700 / 800 instead of 700 / 800 / 900, error text on tinted fills error 600 instead of 700), and dark mode is the Figma library's dark mapping. Label contrast over the level 0 and level 1 surfaces, every platform: light 5.38:1 or more, dark 6.05:1 or more, except the known failures tracked as a known issue: accent error at rest 4.46:1 (both modes), subtle error hover 4.35:1 and press 3.28:1, ghost error press 4.35:1 (light). Focus ring light 5.46:1 or more, dark 9.61:1 or more on every level. Sizes, spacing, radii, border, type, icons and motion now alias the Figma library's scales and the icon font, line height, letter spacing and motion additions directly, instead of the repository's invented semantic tokens (control.*, space.inset.*, radius.control.*, border.width.*, text.control.*, icon.*, motion.control.*): same values, no visual change. iOS and Android lose the overlays that raised the extra small, small and medium heights to 44 pt and 48 dp.0.9.12026-10-01 — Lists Dropdown as related: a button opening a menu of actions is that component, built on this one. The template accepts an attrs prop (extra HTML attributes, a class appended to ds-button) so a component composing Button wires it without copying its markup. No visual change: tokens and CSS unchanged.0.9.02026-10-01 — Decision color-modes. The system gains a dark mode (tokens/modes/dark/, the prototype's Colors / Mapped dark) next to the default light mode. Component tokens, CSS and template unchanged: the button reads the same variables, which resolve again in every data-theme context (light, dark, auto) on the web and in the per-mode trees of the other platforms. The error fill and border keep their light values in dark mode (one step darker than the prototype, whose error 25 on error 500 is 4.47:1). Dark contrast, every emphasis x intent x enabled state on both surfaces: label 6.05:1 minimum (accent error, rest), focus ring 11.73:1 or more against the surfaces. Light unchanged: label 4.76:1 minimum, focus ring 5.47:1 or more.0.8.12026-10-01 — Lists Icon button as related: icon-only actions are that component. No visual change: tokens, CSS and template unchanged.0.8.02026-10-01 — Decision icons-material-symbols. The placeholder stroke SVG icons become Material Symbols Outlined glyphs (Google Fonts, fill 0, weight 300, grade 0, optical size 20), written as ligature names: arrow_forward, close and add replace arrow-right, x and plus in the examples. New raw font.icon.* and semantic icon.* tokens; button.icon gains font-family, font-weight, fill, grade and optical-size; icon sizes unchanged. The glyph keeps the label color, so contrast is unchanged (label 4.76:1 minimum, focus ring 5.47:1 or more).0.7.12026-10-01 — Decision focus-ring-strong confirms the 2.4 px focus ring (stroke-width.lg) for every component and adds a guideline citing WCAG 2.4.13 Focus Appearance (AAA): ring 5.47:1 or more against the surface. No visual change: tokens, CSS and template unchanged.0.7.02026-10-01 — Decision radius-by-size. The corner radius becomes a size token: xsmall and small 2 px (corner-radius.2xs), medium and large 4 px (corner-radius.xs); was 8 px at every size. Tighter than the prototype radius hints (4 px for a small button, 8 px for a button). New semantic radius.control.{xs,sm,md,lg} replaces radius.control; CSS reads the radius through --_border-radius set by each size. No color change: contrast unchanged.0.6.02026-10-01 — Decision border-width-hairline. The control border becomes 0.6 px (stroke-width.sm, the prototype's default border for cards, inputs and components; was 1.2 px). Browsers snap it to one device pixel. The border does not identify the button, so WCAG 1.4.11 asks no contrast of it; label contrast (4.76:1 minimum) and focus ring (5.47:1 or more) unchanged. CSS and template unchanged.0.5.22026-10-01 — Example names and ids state the emphasis and intent they combine (Accent neutral, Subtle brand, Ghost error, small…) instead of a single axis. No visual change: tokens, CSS and template unchanged.0.5.12026-10-01 — Decisions defaults, ghost-tint-one-step-behind and error-darker-than-prototype confirm what 0.5.0 implemented. No visual change: spec defaults, tokens, CSS and template unchanged.0.5.02026-10-01 — Decisions emphasis-axis, intent-axis, size-scale, ai-actions-own-look. The variant property becomes emphasis (accent, subtle, ghost) and a new intent property (neutral, brand, error) combines with it; sizes become xsmall, small, medium, large (xsmall new, 24 px; 44 pt on iOS, 48 dp on Android). Colors follow the prototype mapping (bg.accent / bg.subtle, text.on-accent / text.on-subtle, border.accent / border.subtle, state.disabled) except error, one step darker so labels pass WCAG 1.4.3. Ghost hover and press borrow the subtle rest and hover fills. Label contrast, every emphasis x intent x enabled state on both surfaces: 4.76:1 minimum (subtle error, pressed), focus ring 5.47:1 or more.0.4.02026-10-01 — New raw tokens from the prototype export: brand blue and ash neutral palette, 1.2 px borders and 2.4 px focus ring (stroke-width scale), large label at 20 px (was 18). Label contrast 5.7:1 or more and focus ring 5.8:1 against the surface. Component tokens, CSS and template unchanged.0.3.02026-09-29 — Platform scoping. States say which inputs produce them (hover: pointer; focus-visible: keyboard). Accessibility statements are universal or scoped to web, iOS or Android, the platform ones cited from Apple HIG and Android developers (44 pt and 48 dp targets, press state, Reduce Motion, decorative icons). Label capitalization rule per platform. iOS and Android token overlays raise small and medium heights to the platform target and set the system font; component tokens unchanged.0.2.02026-09-29 — Accessibility contract: every statement cites trusted sources (WCAG 2.2, APG, WAI-ARIA, AccName, HTML, MDN). Icon part marked decorative. Space key behaviour aligned with APG (the on-key-up claim had no source).0.1.02026-09-29 — Initial draft with primary, secondary and ghost variants in three sizes.