# Buttons

> 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.

- Skill: `blyatiful1/buttons` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add blyatiful1/buttons`
- Raw SKILL.md: https://api.skillmd.com/api/skills/blyatiful1/buttons/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: blyatiful1 (https://skillmd.com/u/blyatiful1)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/blyatiful1/buttons

---


# 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

1. Read SYSTEM.md: accent, radius, easing tokens, and the depth language for filled surfaces.
2. 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.
3. Choose ONE hover motif (see state matrix) and encode it in the component.
4. Wire the size scale and icon slots; add the loading state with locked width.
5. Sweep every view for the one-primary rule; demote extras to secondary/ghost.
6. 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 onClick=` or `<span onClick=` 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.

