Components · Button
Components

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.

Variant
Size
State
<Button>Add review</Button>

Anatomy

Add review
1 · Container
Height from the size token. Horizontal padding only — never a fixed width.
2 · Leading icon
Optional, icon-sm at xs, icon-md otherwise. Always aria-hidden.
3 · Label
Action verb + noun. Sentence case, no trailing period, never Title Case.
4 · Focus ring
2px border-focus, outline-offset: 2px, only on :focus-visible.

Variants

primary
Ink, not the action blue. One per view. If two things look equally primary, one of them is secondary.
secondary
Outline at border-strong — 3.06:1, the WCAG 1.4.11 floor for a control boundary. Hover darkens the border to ink.
See how it works
link
No padding, no height floor — it lives inside a sentence. Weight 500, the only variant that is not 600.
ghost
Toolbars only. Never on a page where it is the only control — an invisible boundary is not a button.
destructive
Functional surfaces only — never on marketing. Uses the text token #b91c1c, not the fill: white on the fill is 3.76:1 and fails.

Sizes

Resolved here

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

StateTreatmentNotes
hoverColour or border shiftsNever scale, translate or shadow. 150ms, colour properties only.
focus-visible2px ring, 2px offsetOne focus style for the whole system. Never a glow, never a fill.
activeReturns to the base colourNo "sinking". The press is confirmed by the result, not by the button.
disabledopacity: .5 + pointer-events: noneNo dedicated grey, so the state reads the same on any surface.
loadingaria-busy, 16px spinner, 70% opacityClicks suppressed but the button stays focusable. Label does not change.

Props

PropTypeDefaultNotes
variant'primary' | 'secondary' | 'link' | 'ghost' | 'destructive''primary''danger' was renamed to 'destructive'
size'xs' | 'md' | 'lg''md''xs' is desktop-pointer only
blockbooleanfalseFull width. The mobile default in the feedback app.
iconOnlybooleanfalseSquare. aria-label becomes required.
loadingbooleanfalseSets aria-busy and suppresses clicks
density'compact' | 'default' | 'comfortable'inheritedSets data-density on its own root. Replaces the old register prop.

Writing the label

Do

Action verb + noun. Destructive names the thing.

Don't

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. xs is the documented desktop-pointer exception and must not appear on a touch surface.
  • iconOnly requires aria-label; the glyph is aria-hidden.
  • disabled removes 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"
/>
Sporting Scouter Design System
Every token on this site is generated from theme.css.