Button

User Interface (UI)

How a button looks, from its parts, variants and colours to its sizes, icons, states and tokens.

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

  1. Container — The <button>, or the <a href> when it navigates. It carries the variant, colour and size.
  2. Leading icon — Optional, and hidden from assistive technologies, since the label already names the action.
  3. Label — The accessible name. When the button is icon-only, it's visually hidden but never removed.
  4. Trailing icon — Optional. It hints at what happens next, like a menu opening or the flow moving on.
  5. 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>
VariantLooksUse it for
solidFilledThe main action of a view, so there's only one per view or group
outlineA border and no fillSecondary actions next to a solid one
softA tinted fillSecondary actions that need a bit more presence, and dense layouts
subtleA tinted fill with a borderThe same as soft, with an edge that holds up on busy backgrounds
ghostNothing until you hover itTertiary actions in toolbars, table rows and icon-only buttons
linkUnderlined textLow-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. */}
ColourIntentFor example
primaryThe main action, in your brand colourSave, Publish, Continue
secondaryA second brand colour, for actions you want to set apartUpgrade, Try Pro
neutralEverything else, and the defaultCancel, Back, Edit, Filter
successCompletes something in a positive wayApprove, Mark as done
infoHelps or explains somethingTake the tour, Show details
warningGoes ahead despite a riskPublish anyway, Override
errorDestroys something, or can't be undoneDelete, 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 */}
SizeMin. heightTextIconPaddingGapRadius
xs24px12px14px8px4px6px
sm32px14px16px12px8px6px
md36px14px16px16px8px8px
lg40px16px20px20px8px8px
xl48px16px20px24px8px12px

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.

At rest (try hovering, pressing or tabbing to it)
Disabled
Disabled, but still focusable
Loading
Pressed: false
Expanded: false
<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>
StateSelectorRecommended look
Hover:hover, inside @media (hover: hover)An 8% state layer
Active:activeA 12% state layer
Focus:focus-visibleA 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.

Publish this post?

It goes live for all subscribers right away.

<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-reverse and order move 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:

TokenUsed for
--color--{color}The solid fill, and the tint of soft and subtle
--color--{color}-contrastThe label and icons on top of the fill
--color--{color}-textThe label and icons on the page, for outline, soft, subtle, ghost and link
--color--focusThe 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:

GetStarted.tsx
import styles from "./GetStarted.module.css";

export function GetStarted() {
  return (
    <Button color="primary" className={styles.pill}>
      Get started
    </Button>
  );
}
GetStarted.module.css
.pill {
  --button--radius: 999px;
}

UI Checklist

RuleLevelScopeRequirementBasisCheck
button/intent-colorsMustBothColours describe the action's intent rather than a hue, and never carry that meaning on their ownWCAG 1.4.1 (A), Open ComponentsReview
button/one-primaryShouldUsageThere's one solid primary button per view or group, and the rest are ranked with variantOpen ComponentsReview
button/contrastMustComponentThe 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 pageWCAG 1.4.3, 1.4.11 (AA)axe color-contrast
button/link-underlinedMustComponentThe link variant is underlined at rest, so it doesn't rely on colour alone to stand out from the text around itOpen Components, beyond WCAG 1.4.1 (A)Review
button/focus-visibleMustComponentKeyboard focus shows an outline at least 2px thick, offset from the button, on :focus-visibleWCAG 2.4.7 (AA), 2.4.13 (AAA)Keyboard
button/target-sizeMustComponentThe button is at least 24 × 24px, unless it's a link inside a sentence, with a 44 × 44px hit area on touch screensWCAG 2.5.8 (AA), 2.5.5 (AAA)axe target-size
button/resizableMustComponentThe button is sized with min-height and padding in rem, and its label wraps instead of being clipped or truncatedOpen Components, beyond WCAG 1.4.4, 1.4.10, 1.4.12 (AA)200% zoom
button/stable-sizeMustComponentNo state changes the button's size or positionOpen ComponentsVisual regression test
button/state-selectorsShouldComponentStates are styled from the attributes that expose themOpen ComponentsReview
button/hover-capableShouldComponentHover styles only apply inside @media (hover: hover)Open ComponentsReview
button/forced-colorsMustComponentThe button's boundary, focus, disabled and pressed states stay visible in forced colours modeOpen ComponentsEmulation
button/more-contrastShouldComponentBorders are drawn at full strength when prefers-contrast: more is setOpen ComponentsEmulation
button/reduced-motionMustComponentNothing turns or slides when prefers-reduced-motion: reduce is setWCAG 2.3.3 (AAA)Emulation

FAQ

Copyright © 2026