Button
A shadcn button recipe with five deviations: an ink primary, a 4px corner, no shadow in any variant, a 32px desktop size, and 600 weight everywhere except link.
Anatomy
Variants
Sizes
The spec says Button "adds xs" to the shadcn set without listing the final set. Three sizes, one per control-height token. shadcn's 36px sm is dropped: it is below the 44px touch floor and above the 32px desktop height, so it can only ever be wrong on one of the two. xs is desktop-pointer only — a keyboard-and-mouse dashboard, never a phone.
States
| State | Treatment | Notes |
|---|---|---|
hover | Colour or border shifts | Never scale, translate or shadow. 150ms, colour properties only. |
focus-visible | 2px ring, 2px offset | One focus style for the whole system. Never a glow, never a fill. |
active | Returns to the base colour | No "sinking". The press is confirmed by the result, not by the button. |
disabled | opacity: .5 + pointer-events: none | No dedicated grey, so the state reads the same on any surface. |
loading | aria-busy, 16px spinner, 70% opacity | Clicks suppressed but the button stays focusable. Label does not change. |
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
variant | 'primary' | 'secondary' | 'link' | 'ghost' | 'destructive' | 'primary' | 'danger' was renamed to 'destructive' |
size | 'xs' | 'md' | 'lg' | 'md' | 'xs' is desktop-pointer only |
block | boolean | false | Full width. The mobile default in the feedback app. |
iconOnly | boolean | false | Square. aria-label becomes required. |
loading | boolean | false | Sets aria-busy and suppresses clicks |
density | 'compact' | 'default' | 'comfortable' | inherited | Sets data-density on its own root. Replaces the old register prop. |
Writing the label
Action verb + noun. Destructive names the thing.
Bare verbs say nothing about what happens next. "Cancel" is the one permitted bare label.
Accessibility
- A native
<button>, or an<a>when it navigates. Never a<div role="button">. - 44px is the hit-target floor in every density.
xsis the documented desktop-pointer exception and must not appear on a touch surface. iconOnlyrequiresaria-label; the glyph isaria-hidden.disabledremoves the button from the tab order. If the user needs to know why it is disabled, don't disable it — let it be pressed and explain the failure.- Loading sets
aria-busy="true"and keeps the label. Swapping the label to "Saving…" moves the accessible name mid-interaction.
React
From @sportingscouter/ui-react. The component adds no visual values of its own — every class comes from the same tv() recipe the design system defines, so the two cannot drift.
import { Button } from '@sportingscouter/ui-react';
import { Plus } from 'lucide-react';
<Button onClick={save}>Add review</Button>
<Button variant="secondary" size="sm">Cancel</Button>
<Button variant="link" href="/events">All events</Button>
<Button variant="destructive" onClick={remove}>Delete event</Button>
{/* loading sets aria-busy, NOT disabled: a disabled button
leaves the tab order, so a screen-reader user who submits
never hears the result. */}
<Button loading={saving}>Publish</Button>
{/* Icon buttons still need a name. */}
<Button
iconOnly
icon={<Plus className="icon-md" />}
aria-label="Add category"
/>theme.css.