Components · Actions
Button
Every button variant in the system. Primary indigo is the CTA; AI purple marks automation and premium; the neutral trio (subtle, outline, ghost) carries supporting actions; danger is the one variant that shifts colour on interaction — darkening in Light, lightening in Dark. 32px tall, 16px paddingX, 8px radius. Note the spec also ships a deprecated brand-lime variant for v0.10 back-compatibility: it is not built here and should not be used, because guardrail 1 reserves brand for identity and never interaction.
Preview
Build with the skill
No package to install — hand this to your AI to generate or migrate the component in your stack.
Apply the Zetta "Button" component to this project using the zetta-design-md skill.
WHEN TO USE
- Migrating or fixing existing code (the common case): audit the current implementation against the `button` spec (variants: primary, ai, primary-tinted, ai-tinted, subtle, outline, ghost, danger, danger-subtle) and CORRECT every deviation — replace off-spec values, don't only add new ones.
- New component: build it to the `button` spec in this project's stack.
AUDIT & FIX (when the component already exists)
Compare it to the skill's spec and fix, don't just append: hardcoded colors/hex, off-scale radius, wrong fonts, dark/navy fills, shadows used in place of borders (or missing borders), brand lime used as a CTA or interaction (must be primary indigo), and any missing or wrong hover / focus / active / disabled / invalid state or missing 2px focus ring. Replace each with the correct Zetta token.
ALWAYS
- Pull the authoritative spec and tokens from the skill — never guess or hardcode values.
- Use this project's own component conventions and framework idioms.
- Honor the Zetta guardrails (brand = identity only, primary = interaction, shadows for overlays) and verify in Light, Dark, and Accessibility. Anatomy & rules
┌──────────────────────────────────┐
│ [icon?] Label text [icon?] │ ← 32px height
└──────────────────────────────────┘
- Container — background + border + radius
- Label — 14px / 500 Geist, centered
- Leading icon (optional) — 16px, inherits textColor, 8px gap
- Trailing icon (optional) — 16px, one per button, not both
- Focus ring — 2px outline at 2px offset (keyboard only)
- Loading spinner — replaces label,
cursor: wait
Solid — high emphasis, palette-filled
| Token | Fill | Text | Focus ring | Use |
|---|---|---|---|---|
button-primary |
{colors.primary} Indigo |
{colors.primary-text} white |
{colors.border-focus} |
Primary CTA — one per action group |
button-ai |
{colors.ai} Purple |
{colors.ai-text} white |
{colors.ai} |
AI / premium / discovery actions |
button-brand |
{colors.brand} Lime |
{colors.brand-text} near-black |
{colors.brand} |
Brand moments, marketing CTAs |
Tinted — medium emphasis, palette wash
| Token | Fill | Text | On hover |
|---|---|---|---|
button-primary-tinted |
{colors.primary-bg} |
{colors.primary} |
Fills to solid primary |
button-ai-tinted |
{colors.ai-bg} |
{colors.ai} |
Fills to solid ai |
Tinted buttons communicate palette membership without the full visual weight of solid. Use them as the "default" state for toggleable actions (selected filter, active mode) within a palette-color context.
Neutral — low-to-no emphasis
| Token | Fill | Text | Border | Use |
|---|---|---|---|---|
button-subtle |
{colors.surface-muted} |
{colors.ink} |
none | Supporting actions alongside a solid primary |
button-outline |
transparent | {colors.ink} |
{colors.border-strong} |
Same weight as subtle; use when border separation matters |
button-ghost |
transparent | {colors.ink} |
none | Toolbar, utility, low-priority actions |
Semantic — destructive
| Token | Fill | Text | Use |
|---|---|---|---|
button-danger |
{colors.danger-solid} |
{colors.on-danger} |
Irreversible actions: delete, remove, purge |
button-danger-subtle |
{colors.danger-bg} |
{colors.danger} |
Secondary destructive — "Cancel plan", less permanent than delete |
| State | Background | Border | Text | Notes |
|---|---|---|---|---|
| Default | Variant fill (see variant matrix) | Variant border | Variant text | — |
| Hover | Variant hover fill | Variant border | Variant text | Subtle brightness shift |
| Focus | Variant fill | 2px solid {colors.border-focus} |
Variant text | Keyboard only |
| Active | {colors.surface-pressed} overlay |
Variant border | Variant text | Mouse down |
| Selected | {colors.primary-bg} |
{colors.primary} |
{colors.primary} |
Toggle-style buttons |
| Disabled | {colors.disabled-bg} |
{colors.disabled-border} |
{colors.disabled-text} |
not-allowed cursor |
| Loading | Variant fill | Variant border | Hidden — spinner shown | aria-busy="true" |
When to use which solid variant
- Primary (Indigo) — the top action in any group: "Save", "Submit", "Create". One per group.
- AI (Purple) — AI-generated or discovery actions: "Generate", "Suggest", "Summarize".
- Brand (Lime) — reserved for brand moments: marketing landing pages, onboarding hero CTAs. Rare in product UI.
When to use tinted vs solid
Use tinted when:
- The action is a default/unselected state that can be toggled (e.g. a filter chip).
- You want palette membership without full visual dominance (e.g. alongside a solid primary).
- The surface is already colored and a solid fill would create too much contrast.
Use solid when:
- The action is the single most important action on screen.
- Maximum contrast against a neutral surface is required.
When to use neutral variants
- Subtle — most common supporting action. "Cancel", "Back", "View details".
- Outline — same hierarchy as subtle; prefer when the button is placed on a surface where the border provides useful visual separation.
- Ghost — toolbar actions, icon-adjacent controls, any place where visual weight must be nearly zero.
Do
- One solid primary per action group.
- 8px minimum gap between adjacent buttons.
- Primary action rightmost in a horizontal group.
- Match loading label to the in-progress verb: "Saving…" not "Loading…".
Don't
- Don't put two solid palette buttons side by side — use solid + subtle or solid + outline.
- Don't use
button-brandfor product UI actions — it's a marketing color. - Don't use
button-dangerwithout a confirmation step (dialog or inline confirmation). - Don't use ghost for the primary action.
- Verbs or short verb phrases: "Save", "Delete project", "Send report".
- Sentence case. 1–4 words maximum.
- Danger buttons: be specific — "Delete project", not "Delete".
- Loading: "Saving…", "Uploading…", "Generating…" — match the action verb.
Keyboard
| Key | Behavior |
|---|---|
Tab |
Move focus to the button |
Shift + Tab |
Move focus to the previous element |
Enter / Space |
Activate the button action |
Space |
Toggle pressed state for toggle-style buttons |
Accessibility
- All variant label + background pairs meet 4.5:1 contrast (AA).
- Focus ring: 2px outline at 2px offset. Danger variants use
{colors.danger}outline. - Loading:
aria-busy="true"on the button; spinner has visually hidden "Loading…" label. - Disabled:
disabledattribute removes from focus order. Usearia-disabled="true"only when the button must remain reachable for explanation.
Spec
Values and token references resolved straight from the Zetta spec.
- backgroundColor
- {colors.primary}
- textColor
- {colors.primary-text}
- borderColor
- {colors.primary}
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.primary-hover}
- borderColor
- {colors.primary-hover}
- backgroundColor
- {colors.primary-active}
- borderColor
- {colors.primary-active}
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- 2px
- backgroundColor
- {colors.disabled-bg}
- textColor
- {colors.disabled-text}
- borderColor
- {colors.disabled-border}
- cursor
- not-allowed
- backgroundColor
- {colors.ai}
- textColor
- {colors.ai-text}
- borderColor
- {colors.ai}
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.ai-hover}
- borderColor
- {colors.ai-hover}
- backgroundColor
- {colors.ai-active}
- borderColor
- {colors.ai-active}
- outline
- 2px solid {colors.ai}
- outlineOffset
- 2px
- backgroundColor
- {colors.disabled-bg}
- textColor
- {colors.disabled-text}
- borderColor
- {colors.disabled-border}
- cursor
- not-allowed
- backgroundColor
- {colors.brand}
- textColor
- {colors.brand-text}
- borderColor
- {colors.brand}
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.brand-hover}
- borderColor
- {colors.brand-hover}
- backgroundColor
- {colors.brand-active}
- borderColor
- {colors.brand-active}
- outline
- 2px solid {colors.ink}
- outlineOffset
- 2px
- backgroundColor
- {colors.disabled-bg}
- textColor
- {colors.disabled-text}
- borderColor
- {colors.disabled-border}
- cursor
- not-allowed
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
- borderColor
- transparent
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.primary}
- textColor
- {colors.primary-text}
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- 2px
- backgroundColor
- {colors.disabled-bg}
- textColor
- {colors.disabled-text}
- cursor
- not-allowed
- backgroundColor
- {colors.ai-bg}
- textColor
- {colors.ai}
- borderColor
- transparent
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.ai}
- textColor
- {colors.ai-text}
- outline
- 2px solid {colors.ai}
- outlineOffset
- 2px
- backgroundColor
- {colors.disabled-bg}
- textColor
- {colors.disabled-text}
- cursor
- not-allowed
- backgroundColor
- {colors.surface-muted}
- textColor
- {colors.ink}
- borderColor
- transparent
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
- backgroundColor
- {colors.surface-secondary}
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- 2px
- backgroundColor
- {colors.disabled-bg}
- textColor
- {colors.disabled-text}
- cursor
- not-allowed
- backgroundColor
- transparent
- textColor
- {colors.ink}
- borderColor
- {colors.border-strong}
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
- backgroundColor
- {colors.surface-pressed}
- borderColor
- {colors.border-focus}
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- 2px
- textColor
- {colors.disabled-text}
- borderColor
- {colors.disabled-border}
- cursor
- not-allowed
- backgroundColor
- transparent
- textColor
- {colors.ink}
- borderColor
- transparent
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
- backgroundColor
- {colors.surface-pressed}
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- 2px
- textColor
- {colors.disabled-text}
- cursor
- not-allowed
- backgroundColor
- {colors.danger-solid}
- textColor
- {colors.on-danger}
- borderColor
- {colors.danger-solid}
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.danger-solid-hover}
- borderColor
- {colors.danger-solid-hover}
- backgroundColor
- {colors.danger-solid-active}
- borderColor
- {colors.danger-solid-active}
- outline
- 2px solid {colors.danger}
- outlineOffset
- 2px
- backgroundColor
- {colors.disabled-bg}
- textColor
- {colors.disabled-text}
- borderColor
- {colors.disabled-border}
- cursor
- not-allowed
- backgroundColor
- {colors.danger-bg}
- textColor
- {colors.danger}
- borderColor
- transparent
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- lineHeight
- 1.40
- height
- 32px
- paddingX
- {spacing.md}
- gap
- {spacing.sm}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- backgroundColor
- {colors.danger-solid}
- textColor
- {colors.on-danger}
- outline
- 2px solid {colors.danger}
- outlineOffset
- 2px
- backgroundColor
- {colors.disabled-bg}
- textColor
- {colors.disabled-text}
- cursor
- not-allowed
- cursor
- wait
- pointerEvents
- none
- opacity
- 0.75
- width
- 14px
- height
- 14px
- borderWidth
- 1.5px
- borderStyle
- solid
- borderColor
- currentColor
- borderTopColor
- transparent
- borderRadius
- {rounded.full}
- animation
- spin {motion.duration-slow} linear infinite
Known gaps
- FAB speed-dial (multiple floating actions) — not yet specified.