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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
Sizes
Four heights, all multiples of 4. Medium is the default and should cover roughly 90% of usage.
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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| padding-x | xs / sm / md / lg horizontal padding | |
| gap | Space between icon and label |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-sm | xs button corners | |
| --radius-md | sm and md button corners | |
| --radius-lg | lg button corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e1 | — | Filled and danger resting elevation |
| --shadow-e2 | — | Hover elevation, elevated variant resting |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label | sm and md labels | |
| --text-body-lg | lg label |
Motion
| Token | Value | Used for |
|---|---|---|
| --ease-standard | Colour and shadow transitions | |
| duration | Hover and press feedback |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Icon | Label gap | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|---|---|
| Extra small | 28px | 0 10px | 6px | 13px | 6px | 12px / 540 | 56px | — | 44px (padded) | Inside table rows, chips and dense toolbars. Never as a form’s primary action. |
| Small | 32px | 0 12px | 8px | 14px | 6px | 13px / 540 | 64px | — | 44px (padded) | Card footers, panel headers, secondary toolbars, filter bars. |
| Medium | 36px | 0 14px | 8px | 16px | 8px | 13px / 540 | 72px | 20rem | 44px (padded) | The default. Forms, dialogs, page headers — reach for anything else only with a reason. |
| Large | 44px | 0 20px | 12px | 18px | 8px | 17px / 500 | 96px | 24rem | 44px (native) | Marketing pages, mobile primary actions, checkout. One per screen at most. |
Not a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Moves focus to the button in DOM order. |
| Enter | Activates. On a <button type="submit"> also submits the form. |
| Space | Activates on key-up, so the user can slide off to cancel. |
| ↓ / Alt+↓ | On a split button, opens the options menu. |
| Esc | Closes 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.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-label | IconButton | Required. Without it the button announces as "button" and is unusable by name. |
| aria-busy="true" | button | Set while loading so assistive tech knows the action is in flight rather than ignored. |
| aria-disabled | button | Prefer over the disabled attribute when the button must stay focusable and explain itself. |
| aria-expanded / aria-haspopup | Split button trigger | On the disclosure half only. The primary half stays a plain button. |
| aria-pressed | Toggle buttons | Only for buttons that stay pressed. A button that fires and returns must not use it. |
Example usage
The common cases. Everything else is a combination of these props.
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.
<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.
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| loading | boolean | false | Overlays a spinner, hides the label without unmounting it, sets aria-busy and disables interaction. |
| success | boolean | false | Shows a check for confirmation. Reset it yourself after ~1.5s. |
| startIcon | ReactNode | — | Leading icon. Sized automatically. |
| endIcon | ReactNode | — | Trailing icon, for disclosure or direction. |
| fullWidth | boolean | false | Stretches to the container. Mobile and dialog footers only. |
| iconOnly | boolean | false | Renders square. Prefer <IconButton>, which forces a label. |
| disabled | boolean | false | Use sparingly — see Don’t #2. |
IconButton
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | string | — | Accessible name. There is no fallback. |
| icon* | ReactNode | — | The glyph. Sized from the button size. |
| variant | ButtonVariant | 'text' | Same ladder as Button. |
| size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Renders as a square of this height. |
SplitButton
| Prop | Type | Default | Description |
|---|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| icon* | ReactNode | — | Usually a plus. Keep it universal. |
| label* | string | — | Accessible name, and the visible text when extended. |
| extended | boolean | false | Shows 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. |
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.