User Interface (UI)
Let's start with how a button looks: its parts, how much attention it asks for, what it means, how big it is and how it shows each of its states.
Anatomy
- Container — The
<button>, or the<a href>when it navigates. It carries the variant, colour and size. - Leading icon — Optional, and hidden from assistive technologies, since the label already names the action.
- Label — The accessible name. When the button is icon-only, it's visually hidden but never removed.
- Trailing icon — Optional. It hints at what happens next, like a menu opening or the flow moving on.
- Focus ring — An outline for keyboard focus, drawn outside the button so nothing moves.
Every button has a container and a label. Icons are optional, the focus ring appears when you reach the button with the keyboard, and a Spinner covers the content while the button is loading. The parts always render in the same order, which you can see in the DOM contract.
Variants
The variant prop sets the button's emphasis, meaning how much attention it asks for. We recommend using it to rank the actions in a view, rather than to decorate them.
<Button color="primary">Solid</Button>
<Button color="primary" variant="outline">Outline</Button>
<Button color="primary" variant="soft">Soft</Button>
<Button color="primary" variant="subtle">Subtle</Button>
<Button color="primary" variant="ghost">Ghost</Button>
<Button color="primary" variant="link">Link</Button>
| Variant | Looks | Use it for |
|---|---|---|
solid | Filled | The main action of a view, so there's only one per view or group |
outline | A border and no fill | Secondary actions next to a solid one |
soft | A tinted fill | Secondary actions that need a bit more presence, and dense layouts |
subtle | A tinted fill with a border | The same as soft, with an edge that holds up on busy backgrounds |
ghost | Nothing until you hover it | Tertiary actions in toolbars, table rows and icon-only buttons |
link | Underlined text | Low-emphasis actions that sit among text |
Keep in mind that link is only a look. A button with variant="link" still runs an action, so if you want it to navigate, give it an href as well. It's underlined even at rest, because its colour alone isn't enough to tell it apart from the text around it (WCAG 1.4.1), and we recommend thickening the underline on hover.
Colours
The color prop describes the intent behind an action rather than its hue. A name like primary will still make sense after a rebrand, while blue might not. The label is what carries the meaning, so people who can't tell the colours apart, or who browse with forced colours, still get the same message (WCAG 1.4.1).
<Button color="primary">Solid</Button>
<Button color="secondary">Solid</Button>
<Button color="neutral">Solid</Button>
<Button color="success">Solid</Button>
<Button color="info">Solid</Button>
<Button color="warning">Solid</Button>
<Button color="error">Solid</Button>
{/* Every colour works with every variant. */}
| Colour | Intent | For example |
|---|---|---|
primary | The main action, in your brand colour | Save, Publish, Continue |
secondary | A second brand colour, for actions you want to set apart | Upgrade, Try Pro |
neutral | Everything else, and the default | Cancel, Back, Edit, Filter |
success | Completes something in a positive way | Approve, Mark as done |
info | Helps or explains something | Take the tour, Show details |
warning | Goes ahead despite a risk | Publish anyway, Override |
error | Destroys something, or can't be undone | Delete, Remove, Revoke |
Note that secondary doesn't mean a secondary action, even though its name sounds like it. For a secondary action, it's best to lower the variant instead, for example with variant="outline" next to a solid primary button. Every colour reaches a contrast of 4.5:1 in every variant, at rest, on hover and when pressed, in both light and dark mode (you'll find the details under tokens).
Sizes
The size prop scales the height, text, icons, padding and corner radius together, and md is the default. We recommend sticking to one size per region of the page, and changing it for the whole region rather than for individual buttons.
<Button size="xs" color="primary" variant="soft" leading={<PlusIcon />}>
Add xs
</Button>
{/* …sm, md, lg, xl */}
<Button size="xs" label="Settings" variant="outline" iconOnly leading={<SettingsIcon />} />
{/* …sm, md, lg, xl */}
| Size | Min. height | Text | Icon | Padding | Gap | Radius |
|---|---|---|---|---|---|---|
xs | 24px | 12px | 14px | 8px | 4px | 6px |
sm | 32px | 14px | 16px | 12px | 8px | 6px |
md | 36px | 14px | 16px | 16px | 8px | 8px |
lg | 40px | 16px | 20px | 20px | 8px | 8px |
xl | 48px | 16px | 20px | 24px | 8px | 12px |
Sizes are set in rem, and the table shows the values we recommend, on a 4px grid, at the default text size of 16px. Every size meets the 24 × 24px minimum from WCAG 2.5.8, except for a short link label inside a sentence, which WCAG allows. On touch screens, each one also gets a 44 × 44px hit area without looking any bigger. The heights are minimums, so the button grows with larger text or with a label that wraps.
Icons
Icons are there to support the label, and they should only replace it in the few icon-only buttons that everyone recognises. You can use any icon library you like (the examples use Lucide's, like PlusIcon) and place the icons in the leading and trailing slots.
<Button color="primary" leading={<PlusIcon />}>
New project
</Button>
<Button variant="outline" trailing={<ArrowRightIcon />}>
Continue
</Button>
<Button label="Delete" variant="ghost" color="error" iconOnly leading={<TrashIcon />} />
- A leading icon reinforces the action, like a plus for creating something or a bin for deleting it. A trailing icon hints at what comes next: a chevron opens a menu, an arrow moves you forward, and an arrow pointing out of a box takes you to another site.
- Icons are decorative, so the slots wrap them in
aria-hidden="true". This way, screen readers announce the label once, instead of something like "plus icon, New project". - Only make a button icon-only when the icon is universally understood (think close, search, settings or more) and space is tight. The button still keeps its label, just visually hidden, because screen readers read it, voice control users say it out loud and agents use it to find the button. It's a good idea to show the label in a tooltip too.
- In right-to-left layouts, remember to mirror the icons that point along the reading direction, such as arrows.
States
Each state has a look of its own, styled from the attribute that exposes it. That way, what people see and what assistive technologies report can never drift apart, and a button can't look disabled without actually being disabled.
<Button color="primary">Save</Button>
<Button color="primary" disabled>Save</Button>
<Button color="primary" disabled focusableWhenDisabled>Save</Button>
<Button color="primary" loading loadingLabel="Saving">Save</Button>
<Button
variant="ghost"
aria-pressed={muted}
onClick={() => setMuted(!muted)}
leading={muted ? <VolumeXIcon /> : <Volume2Icon />}
>
Mute
</Button>
<Button
variant="outline"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen(!open)}
trailing={<ChevronDownIcon />}
>
Options
</Button>
| State | Selector | Recommended look |
|---|---|---|
| Hover | :hover, inside @media (hover: hover) | An 8% state layer |
| Active | :active | A 12% state layer |
| Focus | :focus-visible | A 2px outline, 2px from the edge, in --color--focus |
| Disabled | :disabled or [aria-disabled="true"] | 50% opacity, a not-allowed cursor and no state layer |
| Loading | [data-loading] | A spinner over the content and a progress cursor |
| Pressed | [aria-pressed="true"] | A 12% state layer |
| Expanded | [aria-expanded="true"] | A 12% state layer |
For hover and press, we recommend state layers, which lay the label's own colour over the background at 8% and 12%, much like the state layers in Material Design. A single rule covers all 42 combinations of colour and variant, and it keeps contrast predictable, since the tokens still pass 4.5:1 with the layers applied.
None of the states change the button's size or position, so there's no bolder label on hover and no border that appears on focus. A target that moves under your pointer is easy to miss. You'll find how each state behaves under Every state.
Hierarchy and placement
Try to keep a single primary action per view or group, and make it the only solid primary button. When every button competes for attention, none of them stands out. You can rank the rest with variant, using outline or soft for secondary actions and ghost or link for tertiary ones.
<Button variant="ghost">Cancel</Button>
<Button variant="outline">Schedule…</Button>
<Button color="primary">Publish post</Button>
- Keep the order the same across your product. In our examples, the primary action comes last, where the eye ends up, which is on the right in left-to-right layouts and on the left in right-to-left ones.
- Keep the DOM order and the visual order the same. Properties like
flex-direction: row-reverseandordermove buttons around visually but not for the keyboard, which then jumps around unexpectedly (WCAG 1.3.2, 2.4.3). - We recommend leaving at least 8px between buttons, so that a slightly missed tap doesn't land on the one next to it.
- Size buttons to fit their label. On narrow screens, stack them in the same order, and they'll stretch to full width on their own inside a flex column. The width is up to the layout, which is why the Button doesn't have a prop for it.
Tokens
The button reads three theme tokens per colour, plus one focus colour that every component shares:
| Token | Used for |
|---|---|
--color--{color} | The solid fill, and the tint of soft and subtle |
--color--{color}-contrast | The label and icons on top of the fill |
--color--{color}-text | The label and icons on the page, for outline, soft, subtle, ghost and link |
--color--focus | The focus ring, which is the same for every component |
Everything else is derived from these. We recommend mixing 12% of the fill into the page for the soft variant, and the label colour into borders and state layers. Our reference tokens come from the Tailwind CSS palette and pass 4.5:1 for every colour and variant, at rest, on hover and when pressed, on both white and zinc-900. If you change them, make sure to check the same pairs.
:root {
--color--primary: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935));
--color--primary-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823));
--color--primary-text: light-dark(oklch(45.7% 0.24 277.023), oklch(78.5% 0.115 274.713));
/* …secondary, neutral, success, info, warning and error */
--color--focus: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935));
}
If you need to tune a button, you can set its own variables, such as --button--height, --button--padding or --button--radius, from a class. The Button's styles sit in the components cascade layer, so your class wins without any specificity tricks:
import styles from "./GetStarted.module.css";
export function GetStarted() {
return (
<Button color="primary" className={styles.pill}>
Get started
</Button>
);
}
.pill {
--button--radius: 999px;
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
button/intent-colors | Must | Both | Colours describe the action's intent rather than a hue, and never carry that meaning on their own | WCAG 1.4.1 (A), Open Components | Review |
button/one-primary | Should | Usage | There's one solid primary button per view or group, and the rest are ranked with variant | Open Components | Review |
button/contrast | Must | Component | The label has a contrast of 4.5:1 at rest, on hover and when pressed, in every variant, and the focus ring has 3:1 against the page | WCAG 1.4.3, 1.4.11 (AA) | axe color-contrast |
button/link-underlined | Must | Component | The link variant is underlined at rest, so it doesn't rely on colour alone to stand out from the text around it | Open Components, beyond WCAG 1.4.1 (A) | Review |
button/focus-visible | Must | Component | Keyboard focus shows an outline at least 2px thick, offset from the button, on :focus-visible | WCAG 2.4.7 (AA), 2.4.13 (AAA) | Keyboard |
button/target-size | Must | Component | The button is at least 24 × 24px, unless it's a link inside a sentence, with a 44 × 44px hit area on touch screens | WCAG 2.5.8 (AA), 2.5.5 (AAA) | axe target-size |
button/resizable | Must | Component | The button is sized with min-height and padding in rem, and its label wraps instead of being clipped or truncated | Open Components, beyond WCAG 1.4.4, 1.4.10, 1.4.12 (AA) | 200% zoom |
button/stable-size | Must | Component | No state changes the button's size or position | Open Components | Visual regression test |
button/state-selectors | Should | Component | States are styled from the attributes that expose them | Open Components | Review |
button/hover-capable | Should | Component | Hover styles only apply inside @media (hover: hover) | Open Components | Review |
button/forced-colors | Must | Component | The button's boundary, focus, disabled and pressed states stay visible in forced colours mode | Open Components | Emulation |
button/more-contrast | Should | Component | Borders are drawn at full strength when prefers-contrast: more is set | Open Components | Emulation |
button/reduced-motion | Must | Component | Nothing turns or slides when prefers-reduced-motion: reduce is set | WCAG 2.3.3 (AAA) | Emulation |
FAQ
cursor: pointer, since that's the convention on the web and people read the hand as "this does something". Operating systems use the arrow for their own buttons, though, and some design systems follow them. Either choice is fine, as long as all your buttons agree. What matters more is the cursor over disabled (not-allowed) and loading (progress) buttons.