Skip to content

Button

The element users press to make something happen. Eight variants, four sizes, and one rule: only one of them can be the most important thing on the screen.

Also called CTA, Icon Button, FAB, Floating Action Button, Link Button — in this system all of them are Button.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Variants

Read top to bottom as a ladder. If two buttons in a view have the same variant, they should genuinely be of the same importance.

Emphasis ladder — exactly one filled button per view

Semantic — colour only when the outcome is genuinely different

Icon buttons — square, always with an accessible name

Grouped and split

Floating action button — one per screen, never two

Sizes

Four heights, all multiples of 4. Medium is the default and should cover roughly 90% of usage.

xs · 28px
sm · 32px
md · 36px
lg · 44px

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Default
HoverLighter fill, one elevation step up
Focus2px ring, 2px offset
Pressed98.5% scale, shadow removed
Disabled45% opacity, no pointer
LoadingWidth held constant
SuccessReverts after ~1.5s
DangerDestructive, irreversible

Anatomy

Every part, every measurement, and the reason it is that number.

Medium button, filled variant. The hatched area is padding; the dashed outline is the pointer target, which is larger than the visual box on touch devices.

  1. Horizontal padding14px (md)

    Roughly 2× the 8px icon–label gap. Less than 12px and the label looks glued to the edge; more than 20px and short labels float in the middle of an oversized slab.

  2. Corner radius8px · --radius-md

    Matches inputs and selects so controls on the same row share a silhouette. Pills (fully rounded) are reserved for chips and badges, which are not pressable in the same way.

  3. Icon16px, 1.75 stroke

    Sub-linear scaling: a 1.5× taller button gets a 1.2× larger icon. Matching the icon to the cap height rather than the line height keeps it optically aligned with the text.

  4. Height36px = 9 × 4px

    On the 4px grid, so a button aligns with an input, a select and a segmented control without any per-component nudging.

  5. Elevation--shadow-e1 → e2 on hover

    One step, not three. The shadow says "this is above the page"; a bigger jump says "this is flying", which is a different and mostly unwanted message.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-accent—Filled background
--ds-accent-hover—Filled hover background
--ds-accent-active—Filled pressed background
--ds-accent-fg—Label and icon on filled
--ds-accent-subtle—Tonal background
--ds-border-interactive—Outlined border
--ds-layer-hover—Text and outlined hover wash
--ds-danger—Destructive background
--ds-focus-ring—Focus outline

Spacing

TokenValueUsed for
padding-xxs / sm / md / lg horizontal padding
gapSpace between icon and label

Radius

TokenValueUsed for
--radius-smxs button corners
--radius-mdsm and md button corners
--radius-lglg button corners

Shadow

TokenValueUsed for
--shadow-e1—Filled and danger resting elevation
--shadow-e2—Hover elevation, elevated variant resting

Typography

TokenValueUsed for
--text-labelsm and md labels
--text-body-lglg label

Motion

TokenValueUsed for
--ease-standardColour and shadow transitions
durationHover and press feedback

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusIconLabel gapTypeMin widthMax widthTouch targetWhen to use
Extra small28px0 10px6px13px6px12px / 54056px—44px (padded)Inside table rows, chips and dense toolbars. Never as a form’s primary action.
Small32px0 12px8px14px6px13px / 54064px—44px (padded)Card footers, panel headers, secondary toolbars, filter bars.
Medium36px0 14px8px16px8px13px / 54072px20rem44px (padded)The default. Forms, dialogs, page headers — reach for anything else only with a reason.
Large44px0 20px12px18px8px17px / 50096px24rem44px (native)Marketing pages, mobile primary actions, checkout. One per screen at most.

Do

Give every view exactly one filled buttonThe filled variant is the answer to "what did you bring me here to do?". Two answers is the same as none — users fall back to reading every label, which is the cost the emphasis ladder exists to avoid.
Label with a verb the user would say out loud"Delete 3 projects" tells the user what will happen and how much of it. "OK" and "Submit" force them to re-read the dialog to reconstruct the consequence.
Keep the width fixed while loadingThe spinner is overlaid and the label is hidden, not removed. A button that shrinks mid-click moves the target out from under the pointer and invites a second, accidental click elsewhere.
confirmation required
Put destructive actions behind a different colour and a confirmationRed is the only place colour alone is allowed to change meaning, and even then it is paired with an explicit verb. Users have learned red = irreversible; borrowing it for "clear filters" spends that trust.

Don't

Do not stack multiple filled buttonsEvery button becomes the primary button, so none of them is. This is the single most common cause of "the app feels cluttered" feedback that gets misdiagnosed as a spacing problem.
…but why?
Do not disable a button without saying whyA greyed-out button is a dead end: it fails contrast, it usually cannot receive focus, and it never explains itself. Keep it enabled and explain the problem on submit, or show the blocking reason next to it.
Do not use an icon-only button for an ambiguous actionIcons are recognised, not read. Outside a tiny set of universals (close, search, add, more) an unlabelled glyph is a guessing game, and a tooltip does not help on touch.
Do not invent a heightA 38px button next to a 36px input is not "close enough" — the misalignment is visible even to people who cannot name it, and it multiplies across every screen that copies the pattern.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.3Contrast (Minimum)AA2.1.1KeyboardA2.4.7Focus VisibleAA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

Contrast

  • Label against the button fill must reach 4.5:1. Our filled variant is white on #7C6CFF → 4.63:1 in dark, white on #6A55F2 → 5.71:1 in light.
  • The button’s own edge against the page must reach 3:1 for outlined and text variants, or the control is invisible to low-vision users until hovered.
  • The focus ring must reach 3:1 against both the button and the surrounding surface — this is why the ring sits at 2px offset rather than flush.
  • Disabled controls are exempt from contrast requirements, which is exactly why "disable it" is not an accessibility solution.

Keyboard

TabMoves focus to the button in DOM order.
EnterActivates. On a <button type="submit"> also submits the form.
SpaceActivates on key-up, so the user can slide off to cancel.
↓ / Alt+↓On a split button, opens the options menu.
EscCloses an open split-button menu and returns focus to the trigger.

Screen readers

  • The accessible name comes from the visible label. Do not add an aria-label that differs from it — voice-control users say what they see.
  • Loading and success states expose a visually hidden "Loading" / "Done" string so the change is announced, not just drawn.
  • Icons inside buttons are aria-hidden. Announcing "save icon Save changes button" is noise.
  • Never nest a button inside an anchor or vice versa: the accessibility tree becomes ambiguous and activation behaviour differs per browser.

Focus & touch

  • A 2px solid ring in --ds-focus-ring at 2px offset, applied with :focus-visible so pointer users never see it and keyboard users always do. The ring is never removed — if it clashes with a layout, the layout is wrong.
  • Every size renders a 44 × 44 pointer target via an ::after overlay applied on coarse pointers, so a 28px toolbar button is still thumb-safe on a tablet without inflating the desktop layout. Adjacent targets keep at least 8px of clear space.
AttributeApplied toNotes
aria-labelIconButtonRequired. Without it the button announces as "button" and is unusable by name.
aria-busy="true"buttonSet while loading so assistive tech knows the action is in flight rather than ignored.
aria-disabledbuttonPrefer over the disabled attribute when the button must stay focusable and explain itself.
aria-expanded / aria-haspopupSplit button triggerOn the disclosure half only. The primary half stays a plain button.
aria-pressedToggle buttonsOnly for buttons that stay pressed. A button that fires and returns must not use it.

Code

Example usage

The common cases. Everything else is a combination of these props.

tsx
1import { Button, IconButton, SplitButton, Fab } from '@/ui/Button'2import { Save, Trash2, Plus, Copy } from 'lucide-react'34// Primary action — one per view5<Button onClick={save}>Save changes</Button>67// With an icon and a loading state that holds its width8<Button startIcon={<Save />} loading={isSaving}>9  Save changes10</Button>1112// Secondary and tertiary13<Button variant="outlined">Save draft</Button>14<Button variant="text">Cancel</Button>1516// Destructive — always paired with a confirmation step17<Button variant="danger" startIcon={<Trash2 />} onClick={confirmDelete}>18  Delete 3 projects19</Button>2021// Icon only — aria-label is required, not optional22<IconButton label="Copy to clipboard" icon={<Copy />} variant="text" />2324// Default action plus its variants25<SplitButton26  label="Deploy"27  onAction={deployToStaging}28  options={[29    { label: 'Deploy to production', description: 'Requires two approvals' },30    { label: 'Cancel queued deploy', danger: true },31  ]}32/>3334// One FAB per screen, maximum35<Fab icon={<Plus />} label="New project" extended />

Framework-free HTML

No framework required. The classes below map 1:1 to the tokens, so this snippet behaves identically inside the design system.

html
<button type="button" class="ds-btn ds-btn--filled ds-btn--md">
  <svg class="ds-btn__icon" width="16" height="16" aria-hidden="true">…</svg>
  <span>Save changes</span>
</button>

<!-- Loading: label stays in the DOM so the width never changes -->
<button type="button" class="ds-btn ds-btn--filled ds-btn--md" aria-busy="true" disabled>
  <span class="ds-btn__content is-hidden">Save changes</span>
  <span class="ds-btn__spinner" aria-hidden="true"></span>
  <span class="sr-only">Loading</span>
</button>

<!-- Icon only -->
<button type="button" class="ds-btn ds-btn--text ds-btn--md ds-btn--icon" aria-label="Copy to clipboard">
  <svg width="16" height="16" aria-hidden="true">…</svg>
</button>

CSS

Every value is a token reference. There is not a single literal colour here.

css
.ds-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  white-space: nowrap;
  user-select: none;
  font: inherit;
  font-weight: 540;
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  transition:
    background-color 120ms var(--ease-standard),
    border-color 120ms var(--ease-standard),
    box-shadow 120ms var(--ease-standard),
    transform 120ms var(--ease-standard);
}

/* Guarantees a 44px target on touch without changing desktop layout */
@media (pointer: coarse) {
  .ds-btn::after {
    content: '';
    position: absolute;
    inset-inline: 0;
    top: 50%;
    height: 44px;
    transform: translateY(-50%);
  }
}

.ds-btn:active { transform: scale(0.985); }
.ds-btn:focus-visible {
  outline: 2px solid var(--ds-focus-ring);
  outline-offset: 2px;
}
.ds-btn:disabled {
  opacity: 0.45;
  filter: saturate(0.5);
  pointer-events: none;
}

/* --- sizes --- */
.ds-btn--xs { height: 28px; padding-inline: 10px; font-size: 12px; border-radius: var(--radius-sm); }
.ds-btn--sm { height: 32px; padding-inline: 12px; font-size: 13px; }
.ds-btn--md { height: 36px; padding-inline: 14px; font-size: 13px; }
.ds-btn--lg { height: 44px; padding-inline: 20px; font-size: 17px; border-radius: var(--radius-lg); }
.ds-btn--icon { padding-inline: 0; aspect-ratio: 1; }

/* --- variants --- */
.ds-btn--filled {
  background: var(--ds-accent);
  color: var(--ds-accent-fg);
  box-shadow: var(--shadow-e1);
}
.ds-btn--filled:hover { background: var(--ds-accent-hover); box-shadow: var(--shadow-e2); }
.ds-btn--filled:active { background: var(--ds-accent-active); box-shadow: none; }

.ds-btn--outlined {
  border-color: var(--ds-border-interactive);
  color: var(--ds-fg);
}
.ds-btn--outlined:hover {
  border-color: var(--ds-border-strong);
  background: var(--ds-layer-hover);
}

.ds-btn--text { color: var(--ds-fg-secondary); }
.ds-btn--text:hover { background: var(--ds-layer-hover); color: var(--ds-fg); }

.ds-btn--danger {
  background: var(--ds-danger);
  color: var(--ds-danger-fg);
  box-shadow: var(--shadow-e1);
}

@media (prefers-reduced-motion: reduce) {
  .ds-btn { transition-duration: 1ms; }
  .ds-btn:active { transform: none; }
}

Component API

Button

PropTypeDefaultDescription
variant'filled' | 'tonal' | 'outlined' | 'text' | 'elevated' | 'danger' | 'danger-outline' | 'success''filled'Position on the emphasis ladder. One filled button per view.
size'xs' | 'sm' | 'md' | 'lg''md'Height and padding preset. Do not override with a className.
loadingbooleanfalseOverlays a spinner, hides the label without unmounting it, sets aria-busy and disables interaction.
successbooleanfalseShows a check for confirmation. Reset it yourself after ~1.5s.
startIconReactNode—Leading icon. Sized automatically.
endIconReactNode—Trailing icon, for disclosure or direction.
fullWidthbooleanfalseStretches to the container. Mobile and dialog footers only.
iconOnlybooleanfalseRenders square. Prefer <IconButton>, which forces a label.
disabledbooleanfalseUse sparingly — see Don’t #2.

IconButton

PropTypeDefaultDescription
label*string—Accessible name. There is no fallback.
icon*ReactNode—The glyph. Sized from the button size.
variantButtonVariant'text'Same ladder as Button.
size'xs' | 'sm' | 'md' | 'lg''md'Renders as a square of this height.

SplitButton

PropTypeDefaultDescription
label*string—The default action’s label.
onAction() => void—Fired by the primary half.
options*{ label, description?, onSelect?, danger? }[]—Menu contents. Put the most common variant first.
variant'filled' | 'outlined' | 'elevated''filled'Applied to both halves.

Fab

PropTypeDefaultDescription
icon*ReactNode—Usually a plus. Keep it universal.
label*string—Accessible name, and the visible text when extended.
extendedbooleanfalseShows the label. Use when the action is not obvious from the icon.
size'sm' | 'md' | 'lg''md'40 / 56 / 64px square.
tone'accent' | 'surface''accent'Surface tone for FABs over colourful content.

Notes

Professional tips

  • Order actions as Cancel → Secondary → Primary on desktop (left to right, primary last, matching the F-pattern exit point). On mobile, stack them with the primary on top and full width.
  • Sentence case, not Title Case. "Save changes" reads faster than "Save Changes" because lowercase word shapes are more distinctive.
  • Cap a label at three words. If you need more, the button is doing a job that belongs to a heading or an inline description.
  • For "Copy", swap the icon to a check for 1.5s instead of firing a toast. The feedback should appear where the user is looking.
  • A button that opens a dialog should end its label with an ellipsis ("Invite people…") — an old macOS convention that still reliably signals "more input required".

Performance

  • Transition only background-color, border-color, box-shadow and transform. Adding `all` forces the browser to watch every property and drops frames on hover-heavy toolbars.
  • Prefer transform: scale() over changing width or padding for the pressed state — transform is composited and never triggers layout.
  • In long lists, one delegated click handler on the container beats one closure per row; a 500-row table saves 500 function allocations per render.
  • Icon imports must be named (`import { Save } from "lucide-react"`), never a namespace import — the latter defeats tree-shaking and adds ~600 kB.

Common mistakes

  • Using a <div> with onClick. It is not focusable, it does not fire on Enter or Space, and it announces as nothing. If it presses, it is a <button>.
  • Forgetting type="button" inside a form. The default is "submit", so an innocuous "Add row" button reloads the page.
  • Removing outline on :focus instead of restyling it. This is the single most common accessibility regression in production code.
  • Putting the loading spinner beside the label instead of over it. The button grows, the layout jumps, and the user clicks the wrong thing.
  • Colouring a non-destructive action red because it "feels important". Red means irreversible; spending it elsewhere means users stop trusting it where it matters.

Real-world recommendations

  • In a dialog footer, right-align and let the primary sit closest to the corner the eye exits from. In a form, left-align under the fields, on the same axis as the labels.
  • For dangerous operations, require typing the resource name rather than just a second click. Two clicks is muscle memory; typing "production-db" is a decision.
  • Rate-limit at the UI layer: after the first click, go straight to loading. Debouncing the handler is not enough — the user still sees a button that appears to do nothing.
  • If a button triggers work longer than about 10 seconds, do not hold it in a loading state. Return immediately, show progress somewhere persistent, and let the user leave the page.
  • Audit your product for filled buttons per screen. Any view with more than one is a design bug, and it is the fastest measurable quality metric a team can adopt.