Button
betav0.2.0Primary interaction control for actions such as submit, confirm, cancel, and destructive operations. Structured around three axes: variant (default, destructive, ia, highlight), priority (primary, secondary, tertiary), and size (sm, md). IA and Highlight variants have no primary priority (RULE-04). Supports startIcon, endIcon, loading, and fullWidth props. Contract for the IA/Highlight gradient label: the gradient fills the button's own text nodes only — an element child (a Chip, an inline Icon, any nested component) keeps the foreground colour it resolves for itself, so nesting a component inside a gradient button never turns its content invisible; the label is a centred flex row, so such a child sits 8px from the text on its centre line, like the startIcon/endIcon slots. EXCEPTIONAL: `color` paints a primary button in a PRIMITIVE token of theme.css ("orange-700"…) decided outside the four types (an urgency banner's action); the library keeps the ink (white or black, whichever contrasts more — never under 4.58:1 for any primitive). Not a fifth type: a colour that names a product semantic belongs to `variant` (RFC §9.1).
Variants
Variant × Priority
Sizes
With icons
States
Full width
Exceptional colour override — a primitive token as the fill (RFC §9.1)
Primary only, for a surface whose colour is decided outside the four types (an urgency banner). The library picks white or black ink from the token's value, never under 4.58:1; hover moves the fill away from the ink.
Nested content in a gradient label (ia / highlight)
Examples
<Button>Confirm</Button><Button priority="secondary" size="sm">Cancel</Button><Button variant="destructive" onClick={handleDelete}>Delete report</Button><Button variant="destructive" priority="secondary">Remove</Button><Button priority="tertiary">More options</Button><Button variant="ia" priority="secondary">AI analysis</Button>// element children keep their own colour inside a gradient label
<Button variant="ia" priority="secondary">
<Chip severity="ee" label="EE" />
Ask AI
</Button><Button variant="highlight" priority="secondary">Highlight action</Button><Button startIcon={<Icon name="plus" size={16} />}>Add item</Button><Button endIcon={<Icon name="chevron-down" size={16} />}>Options</Button><Button loading>Saving...</Button><Button fullWidth>Full width action</Button>// asChild merges Button styles onto the child element (e.g. a router link)
<Button asChild>
<a href="/reports">Open reports</a>
</Button>// EXCEPTIONAL — a colour decided outside the four types (here the urgency band a banner button sits on).
// A primitive token of theme.css, never a raw hex. Primary only; the library picks white or black ink, never under 4.58:1.
<Button color="orange-700">Start a trial</Button>Props
| Prop | Type |
|---|---|
variant | "default" | "destructive" | "ia" | "highlight" |
priority | "primary" | "secondary" | "tertiary" |
size | "sm" | "md" |
asChild | boolean |
disabled | boolean |
startIcon | ReactNode |
endIcon | ReactNode |
loading | boolean |
fullWidth | boolean |
type | "button" | "submit" | "reset" — defaults to "button" (§9.2); a button that must submit says so |
color | PrimitiveColorToken — EXCEPTIONAL: a primitive token of theme.css ("orange-700"…) as the fill of a primary button whose colour is decided outside the four types; the library picks the ink (RFC §9.1) |
Accessibility
Uses native <button> semantics, keyboard accessible, visible focus ring (focus-visible:ring-2). default secondary border: --color-filigran-brand-primary-transparency-55 on --bg-elevation-default = 3.78:1 dark / 3.60:1 light ✅ (was --border-elevation-default, 3.14/3.72). destructive secondary border: --color-feedback-error-primary on --bg-elevation-default = 5.18:1 dark / 5.92:1 light ✅ — replaces --color-feedback-error-secondary, which measured 1.97:1 dark / 2.51:1 light against a 3:1 minimum. Disabled borders use --border-elevation-disabled and are exempt (WCAG 1.4.3 Note 1); primary disabled keeps its label on --text-default-primary (11.46:1 dark / 10.84:1 light on the disabled fill), because --text-default-disabled there measures 4.35:1 / 3.69:1. destructive primary hover/active: --text-negative-primary on --color-feedback-error-tertiary = 8.1:1 dark / 8.8:1 light ✅ (hover/active darkens the filled destructive fill; error-tertiary keeps the label legible — replaced error-secondary at 1.8:1 dark / 2.5:1 light). Highlight label: a brand-primary→tonic-accent text gradient in BOTH modes — dark 10.30:1 / 13.14:1, light 11.22:1 / 7.67:1 on --bg-elevation-default ✅. The light-mode exception that painted it SOLID brand-primary (@fimow, 2026-07-30, PR #62, a `.light .text-gradient-focus` override emitted by the token generator) is RETIRED 2026-10-20 (@fimow, Button.rfc.md §9): it existed because the old end stop, tonic-primary (light turquoise-700 #009474), reached only 3.42:1; the 2026-10-20 export ends --gradient-focus on --color-filigran-tonic-accent (turquoise-500 in dark, unchanged; turquoise-800 #005744 in light). Highlight focus ring: --color-filigran-tonic-primary on the --bg-elevation-default offset = 13.14:1 dark / 3.42:1 light ✅ (3:1). The gradient BORDER keeps both stops in both modes (non-text UI, WCAG 1.4.11 ≥ 3:1: tonic-accent light 7.67:1 ✅, was 3.42:1) and is 2px per Figma. Every declared pair passes in every mode it renders. EXCEPTIONAL `color` override (RFC §9.1): the fill is one of the ~100 opaque primitives of theme.css, chosen at the call site, so no single pair is declared — the library picks the ink between --white-1000 and --black-1000 by WCAG relative luminance from the generated primitive map; the two contrast curves cross at luminance 0.1791 where both read 4.58:1, so the chosen ink is never under that for ANY primitive (Button.color.test.tsx sweeps the whole map). Hover/active mix the fill 10% toward the primitive OPPOSITE to the ink (color-mix), so the label's contrast can only rise in the interactive states — never a filter over the whole button, which would darken the ink too. The three colours travel as custom properties written after the caller's style and read by classes, so a caller cannot displace the fill or the ink. Primary only; dropped while disabled or loading so the disabled tokens stay the one shared look. Loading state disables the button and sets aria-busy=true. The button path defaults to type="button" so a button inside a form does not submit it unless it asks to (§9.2); the asChild path carries no type, invalid on an anchor.