Button

The primary action trigger — a labeled, interactive control with a fixed set of variants, sizes, and states, sourced entirely from tokens.

Why it matters

The button is the most-placed, most-copied component in any product, so it’s the canary for the whole system: if it hardcodes a blue, skips a focus ring, or lacks a loading state, that defect propagates everywhere. It also sets the hierarchy contract — exactly one primary action per view — that keeps interfaces decisive. Nailing the button proves the token pipeline and the state model work.

How it works

Three axes: variant (visual weight / intent), size, state.

  • Variantsprimary / secondary / tertiary(ghost) / danger; intent colors from functional-colors, not literals.
  • Sizessm / md / lg, padding and type from defining-design-tokens.
  • States — default / hover / focus-visible / active / disabled / loading; loading shows a loading-indicator and blocks repeat clicks.
  • Semantics — render a real <button> (or <a> if it navigates); never a clickable <div>.
VariantWeightUse
PrimaryHighestThe main action
SecondaryMediumAlternative action
GhostLowTertiary / toolbar
DangerHigh + redDestructive action

Hit target ≥ 44×44px for touch. An icon-only button must have an accessible name (aria-label). See accessibility.

Example

<Button variant="primary" size="md" loading={saving}>Save</Button>. It reads color.action, space.3, radius.md from tokens. On submit, loading swaps the label for a spinner and disables the click so a double-tap can’t fire two requests. Focus shows a visible 2px ring. A sibling variant="ghost" “Cancel” sits beside it — only one primary in the view.

Pitfalls

  • <div> buttons — lose keyboard, focus, and role; use a real <button>. See accessibility.
  • Two primaries in one view — destroys hierarchy; demote one to secondary/ghost.
  • No loading guard — double-clicks fire duplicate submits; disable while pending.
  • Removing the focus ring — kills keyboard usability; restyle :focus-visible, don’t delete it.

See also