Resolved: light

Buttons

One <Button> component (src/components/ui/Button.tsx), built on class-variance-authority. Two axes — variant (primary · secondary · ghost · danger) and size (sm · md · lg) — plus the boolean states loading and disabled, and anything you express through className (full-width, leading icons). It is presentational: it takes onClick from a client parent and is safe in either a server or client tree. Flip the theme in the top bar to confirm every variant re-skins via the semantic tokens.

variant
"primary" (default) | "secondary" | "ghost" | "danger". Sets fill, text, border and (primary only) the solid accent + shadow.
size
"sm" | "md" (default) | "lg". Controls height, horizontal padding and font size only.
loading
boolean. Shows a spinning Loader2 before the label AND sets the button disabled. Default false.
disabled
boolean. Standard button disable: opacity 50%, not-allowed cursor, no hover. loading implies disabled.
className
Merged last via cn() (tailwind-merge). Use it for layout: w-full for full width, etc. NB primary keeps bg-accent + hover:bg-accent-hover on separate modifiers so tailwind-merge won’t collapse them.
children
The label, and optionally a leading lucide icon (gap-2 is built in). Mark decorative icons aria-hidden.
rest props
Spreads ButtonHTMLAttributes — onClick, type, aria-*, name, value … land on the native <button>.
tsx
import { Button } from "@/components/ui";

<Button variant="primary" size="md" onClick={save}>
  Save changes
</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="danger" loading>Deleting…</Button>

Variant × size matrix

Every variant crossed with every size — the complete default (idle) state. Rows are variants, columns are sizes. Heights step 7 → 10 → 11 (sm → md → lg); the visual weight steps primary → secondary → ghost, with danger held apart for destructive actions.

variant \ sizesmmdlg
primary
secondary
ghost
danger
variant (rows) × size (columns) — default / idle state
size="sm"
h-7, px-3, text-xs — dense toolbars / inline actions.
size="md"
h-10, px-4, text-sm — the default (defaultVariants.size).
size="lg"
h-11, px-5, text-sm — prominent primary actions / hero CTAs.

Variants on surface vs background

Each variant shown at the default md size on both bg-surface (cards/panels) and bg-background (the page canvas). Ghost and secondary lean on surface contrast, so they read differently on each — always check the canvas a button will actually live on.

variant="primary"bg-surface · bg-background

Solid bg-[--color-accent-strong] with text-[--color-on-accent], hover brightens, transparent border, a subtle shadow-sm. The one main action per view.

variant="secondary"bg-surface · bg-background

bg-surface-2 / hover:bg-surface-3, primary text, a visible --color-border edge. The neutral companion action.

variant="ghost"bg-surface · bg-background

transparent fill / hover:bg-surface-2, muted text, no border. Low-emphasis / tertiary actions.

variant="danger"bg-surface · bg-background

bg-bad / hover:brightness-110, text-on-danger, transparent border. Reserved for destructive actions only — red is never decorative.

Sizes

The three sizes side by side at the primary variant. Size changes height, horizontal padding and font size only — never the color, weight or radius. Keep one size consistent within a button group.

size="sm" · "md" · "lg" (primary)
size="sm" · "md" · "lg" (secondary)

With a leading icon

Drop a lucide icon as the first child — the component's built-in gap-2 spaces it from the label. Use h-4 w-4 to match the md/lg type size, and mark a purely decorative icon aria-hidden (the text already names the action). For a trailing icon, put it after the label.

leading icons across all four variants (size md)
trailing icon — place the icon after the label
gap-2
Built into the base class — no manual margin needed between icon and label.
icon size
Use h-4 w-4 to align with sm/md/lg label text; the spinner shown while loading is also h-4 w-4.
aria-hidden
Decorative icons get aria-hidden="true" so screen readers announce only the label. For an icon-only button, add an aria-label instead.

Loading

loading renders a spinning Loader2 (h-4 w-4, animate-spin) before the children and disables the button so it can't be re-submitted. Keep the label in place so the control doesn't change width — the spinner is added, not swapped in.

primary
secondary
ghost
danger
loading across every variant (size md)
loading across every size (primary)
loading={true}
Prepends the animate-spin Loader2 and sets the underlying <button> disabled (disabled = disabled || loading).
label stays
Children render alongside the spinner — keep the text (e.g. “Saving…”) so width is stable and the action stays legible.
accessibility
Because the button is disabled while loading it can’t be activated again; pair with aria-live elsewhere if you need to announce completion.

Disabled

disabled drops the button to 50% opacity, switches the cursor to not-allowed and removes hover feedback (the disabled:* utilities live in the base class). Each variant idle → disabled is paired below so the muting is obvious.

primary
secondary
ghost
danger
idle (left) vs disabled (right), per variant
disabled
The <Button> component renders a truly-disabled button as a neutral, clearly-inert surface (muted bg + faint text, no shadow); raw buttonVariants() consumers fall back to the base disabled:opacity-50. A loading button keeps its variant color instead.
disabled:cursor-not-allowed
Cursor feedback that the control is inert; hover background changes are also suppressed.
loading ⊃ disabled
A loading button is always disabled too — you don’t need to pass both.

Full width

There is no fullWidth prop — pass w-full through className. The button is inline-flex with centered content, so it stretches to its container and keeps its label centered. Common in narrow forms, mobile sheets and card footers.

className="w-full" — primary & secondary stacked
className="w-full" at each size
w-full
The full-width recipe — there is no dedicated prop. className is tailwind-merged last so it wins over the base.
centered content
Base class is inline-flex items-center justify-center, so the label/icon stay centered when stretched.

Pairing & emphasis

One primary per view carries the main action; pair it with a secondary or ghost for the lesser action, and reserve danger for genuinely destructive operations. The solid accent fill on primary is what makes it read as the lead — don't promote a second button to primary in the same group.

form footer: primary + secondary
destructive confirmation: danger + ghost escape
primary = solid accent
Only the primary variant carries the solid accent fill + shadow-sm. It is the cue for the single lead action — keep it to one per group.
secondary / ghost
The companion actions. secondary for a real alternative (Save draft), ghost for the quietest escape (Cancel).
danger is reserved
Red (bg-bad) is destructive-only — never use the danger variant for emphasis. Give a destructive action a non-destructive escape (ghost).
never color alone
Meaning is carried by the label (and often an icon), not the hue — danger still says “Delete”.