buttons — every state decided, one primary
Stage: Phase 6 — Build - Reads: design/SYSTEM.md, app/globals.css - Writes: components/ui/button.tsx (restyled variants) + CTA usage rules
Standard
Buttons are the most-touched component on the site and the fastest tell of default-shadcn slop. First-grade: npx shadcn@latest add button, then RESTYLE — radius from the shape language, colors from the token bridge, focus ring palette-matched (taste requires it). Exactly three visual weights plus destructive; all six states explicitly designed; nothing left to browser or library defaults. React 19: the generated file has no forwardRef and carries data-slot="button".
Process
- Read SYSTEM.md: accent, radius, easing tokens, and the depth language for filled surfaces.
- Restyle the generated button.tsx: extend its variant map in place — verify the generated file's shape first; never ad-hoc className soup at call sites.
- Choose ONE hover motif (see state matrix) and encode it in the component.
- Wire the size scale and icon slots; add the loading state with locked width.
- Sweep every view for the one-primary rule; demote extras to secondary/ghost.
- Keyboard-walk every variant: visible ring on all of them, including ghost and icon-only.
Hierarchy — one primary per view
- primary — filled accent. ONE per viewport-height region. The sticky header CTA is the single exemption (it is the site-wide primary); every section owns at most one more.
- secondary — outline or subtle neutral fill; the "also fine" action. Pairs with primary in heroes.
- ghost — text-weight with a hover surface; tertiary actions, toolbars, icon buttons.
- destructive — actual destruction only; never for "cancel".
Two filled buttons side by side = no hierarchy. A CTA that navigates renders as an <a> (next/link) carrying the button variant classes — never a <button> that pushes a route.
Size scale
| size |
height |
padding-x |
text |
icon |
| sm |
32px (h-8) |
12px |
0.875rem |
16px |
| md (default) |
40px (h-10) |
20px |
0.875-1rem |
16px |
| lg |
48px (h-12) |
24px |
1rem |
20px |
| xl (hero only) |
56px (h-14) |
32px |
1.125rem |
20px |
Mobile touch targets ≥44px: primary mobile CTAs use lg, or md with an expanded hit area (relative after:absolute after:-inset-1 — the button must be positioned for the pseudo-element to anchor to it); sm never ships as a mobile primary. Icon-only buttons are square at each height and REQUIRE aria-label.
State matrix — concrete, site-wide
Transition ONLY transform, background-color, border-color, box-shadow — never transition-all.
- default — rest. The designed baseline, not the library's.
- hover — 150-200ms on the SYSTEM easing token. Motif options: background one step darker/lighter (ΔL ≈ 0.04 in oklch), lift translate-y -1px, or trailing-arrow nudge translate-x 2px. Pick ONE motif for all buttons.
- active — 80-100ms, deliberately faster than hover: press is feedback, not animation. Scale 0.98 or lift returns to 0; release snaps back at the hover duration.
- focus-visible —
focus-visible:ring-2 + ring-offset-2, ring in the palette-matched color SYSTEM defines. Never outline-none without this replacement. Same ring on <a> buttons.
- disabled — opacity 60% +
cursor-not-allowed; label stays ≥3:1 readable. Disabled means "this action is not available" — NOT async pending; that's loading.
- loading — lucide
LoaderCircle with animate-spin replaces the leading icon (or slots before the label); label swaps to the progress verb ("Saving…"); width LOCKED via min-width so nothing jumps; disabled + aria-busy="true". Icon-only: spinner replaces the icon and the aria-label updates. Form submits get pending for free via useFormStatus() from 'react-dom' inside the form.
Under prefers-reduced-motion, movement motifs (lift, nudge, press scale) drop to the background/border color shift; the loading spinner may remain as essential status feedback (or swap to a static/pulsing indicator). Policy per ultraweb:motion-language.
Icon placement
- Leading icon describes the action (Plus, Download, Send). Trailing icon shows direction or consequence (ArrowRight, ExternalLink, ChevronDown). Never both on one button.
- 8px gap between icon and label; icon size per the scale table; stroke width consistent per
ultraweb:icons.
- The trailing-arrow hover nudge (translate-x 2-3px, 150ms) is a strong default motif for link-style CTAs — but it counts as THE site hover motif if chosen.
Engineering
- Buttons are server-component-safe as generated. Motion-driven press physics (
whileTap from motion/react) needs "use client" — plain CSS :active covers 95% of cases; escalate only via ultraweb:micro-interactions.
- Label copy comes from
ultraweb:copywriting: verb + object ("Start free trial", "Book a table") — never "Submit", "Click here", "Learn more".
A11y
- Real semantics:
<button type="submit|button"> or <a href> — never <div onClick>.
- Focus-visible ring on EVERY variant, ghost and icon-only included.
- Text on filled primary ≥4.5:1 computed —
ultraweb:color owns the math.
- Loading announces itself:
aria-busy="true" plus the visible label change.
- Touch targets ≥44px on mobile — gate-responsive verifies.
Anti-patterns
transition-all — animates properties you never chose.
hover:scale-110 — a button ballooning 10% is a toy, not a control.
outline-none or focus:outline-none without a focus-visible ring.
- Two filled primaries visible together in one view.
Submit, Click here, Learn more as labels — dead copy (banned list).
<div> or <span> masquerading as a button.
- Spinner-only loading on a labeled button — width jump plus a vanished label.
- Gradient-filled primary as the default look — banned unless DIRECTION.md justifies it.
Worked example — Framewalk, indie game studio Steam launch site
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: buttons3description: Build the button/CTA system — primary/secondary/ghost/destructive hierarchy with the one-primary-per-view rule, a four-step size scale, icon placement rules, and the full state matrix (default, hover, active, focus-visible, disabled, loading) with concrete transforms and timing. Invoke during the Build phase when creating or restyling components/ui/button.tsx, wiring any CTA, submit, or icon button, or when the user says "the buttons feel dead", "add a loading state", "fix the CTA hierarchy", or buttons are shipping with default shadcn styling.4---56# buttons — every state decided, one primary78**Stage:** Phase 6 — Build - **Reads:** design/SYSTEM.md, app/globals.css - **Writes:** components/ui/button.tsx (restyled variants) + CTA usage rules910## Standard1112Buttons are the most-touched component on the site and the fastest tell of default-shadcn slop. First-grade: `npx shadcn@latest add button`, then RESTYLE — radius from the shape language, colors from the token bridge, focus ring palette-matched (taste requires it). Exactly three visual weights plus destructive; all six states explicitly designed; nothing left to browser or library defaults. React 19: the generated file has no forwardRef and carries `data-slot="button"`.1314## Process15161. Read SYSTEM.md: accent, radius, easing tokens, and the depth language for filled surfaces.172. Restyle the generated button.tsx: extend its variant map in place — verify the generated file's shape first; never ad-hoc className soup at call sites.183. Choose ONE hover motif (see state matrix) and encode it in the component.194. Wire the size scale and icon slots; add the loading state with locked width.205. Sweep every view for the one-primary rule; demote extras to secondary/ghost.216. Keyboard-walk every variant: visible ring on all of them, including ghost and icon-only.2223## Hierarchy — one primary per view2425- **primary** — filled accent. ONE per viewport-height region. The sticky header CTA is the single exemption (it is the site-wide primary); every section owns at most one more.26- **secondary** — outline or subtle neutral fill; the "also fine" action. Pairs with primary in heroes.27- **ghost** — text-weight with a hover surface; tertiary actions, toolbars, icon buttons.28- **destructive** — actual destruction only; never for "cancel".2930Two filled buttons side by side = no hierarchy. A CTA that navigates renders as an `<a>` (next/link) carrying the button variant classes — never a `<button>` that pushes a route.3132## Size scale3334| size | height | padding-x | text | icon |35|---|---|---|---|---|36| sm | 32px (h-8) | 12px | 0.875rem | 16px |37| md (default) | 40px (h-10) | 20px | 0.875-1rem | 16px |38| lg | 48px (h-12) | 24px | 1rem | 20px |39| xl (hero only) | 56px (h-14) | 32px | 1.125rem | 20px |4041Mobile touch targets ≥44px: primary mobile CTAs use lg, or md with an expanded hit area (`relative after:absolute after:-inset-1` — the button must be positioned for the pseudo-element to anchor to it); sm never ships as a mobile primary. Icon-only buttons are square at each height and REQUIRE `aria-label`.4243## State matrix — concrete, site-wide4445Transition ONLY transform, background-color, border-color, box-shadow — never `transition-all`.4647- **default** — rest. The designed baseline, not the library's.48- **hover** — 150-200ms on the SYSTEM easing token. Motif options: background one step darker/lighter (ΔL ≈ 0.04 in oklch), lift translate-y -1px, or trailing-arrow nudge translate-x 2px. Pick ONE motif for all buttons.49- **active** — 80-100ms, deliberately faster than hover: press is feedback, not animation. Scale 0.98 or lift returns to 0; release snaps back at the hover duration.50- **focus-visible** — `focus-visible:ring-2` + `ring-offset-2`, ring in the palette-matched color SYSTEM defines. Never `outline-none` without this replacement. Same ring on `<a>` buttons.51- **disabled** — opacity 60% + `cursor-not-allowed`; label stays ≥3:1 readable. Disabled means "this action is not available" — NOT async pending; that's loading.52- **loading** — lucide `LoaderCircle` with `animate-spin` replaces the leading icon (or slots before the label); label swaps to the progress verb ("Saving…"); width LOCKED via min-width so nothing jumps; `disabled` + `aria-busy="true"`. Icon-only: spinner replaces the icon and the `aria-label` updates. Form submits get pending for free via `useFormStatus()` from `'react-dom'` inside the form.5354Under `prefers-reduced-motion`, movement motifs (lift, nudge, press scale) drop to the background/border color shift; the loading spinner may remain as essential status feedback (or swap to a static/pulsing indicator). Policy per `ultraweb:motion-language`.5556## Icon placement5758- Leading icon describes the action (Plus, Download, Send). Trailing icon shows direction or consequence (ArrowRight, ExternalLink, ChevronDown). Never both on one button.59- 8px gap between icon and label; icon size per the scale table; stroke width consistent per `ultraweb:icons`.60- The trailing-arrow hover nudge (translate-x 2-3px, 150ms) is a strong default motif for link-style CTAs — but it counts as THE site hover motif if chosen.6162## Engineering6364- Buttons are server-component-safe as generated. Motion-driven press physics (`whileTap` from `motion/react`) needs `"use client"` — plain CSS `:active` covers 95% of cases; escalate only via `ultraweb:micro-interactions`.65- Label copy comes from `ultraweb:copywriting`: verb + object ("Start free trial", "Book a table") — never "Submit", "Click here", "Learn more".6667## A11y6869- Real semantics: `<button type="submit|button">` or `<a href>` — never `<div onClick>`.70- Focus-visible ring on EVERY variant, ghost and icon-only included.71- Text on filled primary ≥4.5:1 computed — `ultraweb:color` owns the math.72- Loading announces itself: `aria-busy="true"` plus the visible label change.73- Touch targets ≥44px on mobile — gate-responsive verifies.7475## Anti-patterns7677- `transition-all` — animates properties you never chose.78- `hover:scale-110` — a button ballooning 10% is a toy, not a control.79- `outline-none` or `focus:outline-none` without a focus-visible ring.80- Two filled primaries visible together in one view.81- `Submit`, `Click here`, `Learn more` as labels — dead copy (banned list).82- `<div onClick=` or `<span onClick=` masquerading as a button.83- Spinner-only loading on a labeled button — width jump plus a vanished label.84- Gradient-filled primary as the default look — banned unless DIRECTION.md justifies it.8586## Worked example — Framewalk, indie game studio Steam launch site8788Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.8990## Composes with9192Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.