Components

Button

How to build a button that looks right, works for everyone, feels familiar to every developer and can be checked by your agents.

Buttons let people act on what's in front of them, whether that's saving a form, sending a message, opening a dialog or deleting a file. They're the most common interactive component in any interface, and also the one we most often get wrong. Think of a <div> that you can't reach with the keyboard, a form that gets sent twice, or a spinner that throws your focus back to the top of the page.

This page walks you through building one the right way. We'll look at:

  • User Interface (UI) - how a button looks
  • User Experience (UX) - how it behaves for every person and every input
  • Developer Experience (DX) - how developers work with it
  • Agentic Experience (AX) - how agents can read and verify it

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to try things out as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Button with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

<Button color="primary" leading={<CheckIcon />}>
  Save changes
</Button>

At a glance

AspectButton
Element<button type="button">, or <a href> when it takes you somewhere
Rolebutton, or link when it has an href
NameIts label, which stays in the page as hidden text when the button is icon-only
KeyboardEnter and Space activate it, while links only respond to Enter
StatesHover, active, focus, disabled, loading, pressed and expanded
Variantssolid, outline, soft, subtle, ghost and link
Coloursprimary, secondary, neutral, success, info, warning and error
Sizesxs, sm, md, lg and xl
PatternWAI-ARIA APG Button
WCAG 2.21.4.3, 1.4.11, 2.1.1, 2.4.7, 2.5.2, 2.5.3, 2.5.8, 4.1.2 and 4.1.3, among others

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

  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;
}

User Experience (UX)

Now let's look at how a button behaves for every person, input and setting. A good button is accessible, predictable, designed in every state and adaptive.

Accessible

Use the native element

A native <button> gives you its role, focus, Enter and Space, disabled and form submission for free. A <div> has none of these, and every one you add back by hand is one more thing that can go wrong.

{/* Do: you get the role, focus, keyboard and disabled state for free */}
<button type="button" onClick={save}>Save</button>

{/* Don't: there's no role, focus or keyboard support until you rebuild them */}
<div className="button" onClick={save}>Save</div>

Name every button

The label is also the button's accessible name. Screen readers announce it, voice control users say it out loud ("click Save"), and tests and agents use it to find the button.

  • An icon-only button keeps its label, only visually hidden, through label and iconOnly. Hidden text is still ordinary text, so it gets translated along with the rest of the page, which translation tools don't always do for aria-label.
  • If you name a button with aria-label or aria-labelledby, make sure the name includes the visible label, ideally at the start (WCAG 2.5.3). A button that shows "Send" but is named "Submit message" won't respond when someone says "click Send".
  • If there's extra context, put it in a description, which screen readers read after the name. You can do that with aria-describedby, pointing at text like "Deleting is permanent".

Contrast and size

  • The label needs a contrast of at least 4.5:1 with its background, in every variant and state (WCAG 1.4.3), and the focus ring needs 3:1 against the page (WCAG 1.4.11). Disabled buttons are exempt, but they should still be readable.
  • Every button is at least 24 × 24px (WCAG 2.5.8). On touch screens, the hit area grows to 44 × 44px (WCAG 2.5.5), which is the size Apple recommends, while Material recommends 48dp.

Show focus

When you reach a button with the keyboard, it shows an outline in the same focus colour as every other component (WCAG 2.4.7). It needs to be at least 2px thick and drawn outside the button, and we recommend 2px for both. We use :focus-visible, so the ring appears for keyboard users but not on every click, and we draw it with outline, which survives forced colours mode where box-shadow doesn't. You'll also want to keep focused buttons clear of sticky headers and footers, which you can do with scroll-padding (WCAG 2.4.11, 2.4.12).

Don't nest interactive elements

HTML doesn't allow links, buttons, inputs or tabindex inside a button, or a button inside a link. Keyboards and screen readers can't reliably reach the inner control, and a single click ends up activating both. If you'd like a whole card to be clickable, stretch its title link over the card with a pseudo-element, and lift the card's buttons above it.

Predictable

Run actions on click

Always run your actions on click. It fires for the mouse, touch, pen, Enter, Space and assistive technologies, and only when the pointer is released, so people can still change their mind by moving away before letting go (WCAG 2.5.2). Events like mousedown, pointerdown and touchstart fire as soon as you press, and never for the keyboard.

Keyboard

KeyButtonLink (href)
Tab, Shift+TabMoves focus to and from itThe same
EnterActivates itFollows the link
SpaceActivates it, on releaseScrolls the page

A link that looks like a button still behaves like a link, because that's what keyboard and screen reader users expect from the role they hear.

Move focus where people expect it

After a button is activated, focus should go where the APG recommends:

  • A button that opens a dialog moves focus into it, and closing the dialog brings focus back to the button.
  • A button that acts in place, like Apply, keeps its focus.
  • A button that starts a new step moves focus to the start of that step.
  • A button that removes its own content, like Delete in a list, moves focus somewhere that makes sense, such as the next item. Focus should never fall back to the top of the page.

Write labels that say what happens

  • Start with a verb and name the object, as in "Save changes", "Delete project" or "Invite member", rather than "OK", "Yes", "Submit" or "Click here".
  • Keep labels short. We recommend sentence case, without a full stop.
  • Add an ellipsis (…) when the action needs more input before it happens, like "Save as…" or "Schedule…".
  • Use the same label for the same action everywhere (WCAG 3.2.4).

Make destructive actions recoverable

Delete “Marketing site”?

Its 24 pages and their history will be deleted for good.

<Button variant="outline">Keep project</Button>
<Button color="error">Delete project</Button>
  • Use color="error" and name what's being destroyed, so "Delete project" rather than "Delete" or "Yes".
  • For everyday actions, offer undo rather than asking for confirmation. Save confirmations for what can't be undone, especially when data, money or legal commitments are involved (WCAG 3.3.4).
  • In a confirmation dialog, name both choices by their outcome, and don't give the destructive one the initial focus (APG dialog).

Don't submit forms by accident

In HTML, a <button> without a type is a submit button, so inside a form, a "Show password" button would send the form. That's why our Button defaults to type="button", and your submit buttons have to say so explicitly.

<form onSubmit={signIn}>
  <Button onClick={() => setReveal(!reveal)}>Show password</Button>
  <Button type="submit" color="primary">Sign in</Button>
</form>

Every state

Disabled

A disabled button can't be activated, and there are two ways to disable one:

  • disabled sets the native attribute. The button leaves the tab order, and screen readers announce it as dimmed or unavailable when they read through the page.
  • disabled with focusableWhenDisabled sets aria-disabled="true" instead. The button stays in the tab order, so keyboard and screen reader users can still find it, along with whatever explains why it's disabled, while the Button blocks activation on its own.

Reach for focusableWhenDisabled whenever a button can be disabled while it has focus, like Next once you've paged to the end of a list. Native disabled would take its focus away and drop it on the <body>, and keyboard and screen reader users would lose their place. For a button that's busy with its own action, use loading instead, which keeps its focus on its own.

We recommend disabling as little as you can. A disabled Submit button doesn't tell anyone what's missing, so it's usually better to keep it enabled, validate on submit and move focus to the first error. When a button really has to be disabled, explain why next to it, or in a tooltip on a focusable disabled button.

Also, avoid faking a disabled state with pointer-events: none. Clicks fall through to whatever is underneath, tooltips can't open, and the keyboard can still activate the button.

Loading

A loading button is busy with the action it was just asked to do. It keeps its focus, size and name, it can't be activated again, and it lets screen readers know that it's busy.

import { useState } from "react";

export function SaveChanges() {
  const [saving, setSaving] = useState(false);
  const [status, setStatus] = useState("");

  async function save() {
    setSaving(true);
    try {
      await api.save(form);
      setStatus("Changes saved");
    } catch {
      setStatus("Couldn't save your changes. Try again.");
    } finally {
      setSaving(false);
    }
  }

  return (
    <>
      <Button color="primary" loading={saving} loadingLabel="Saving" onClick={save}>
        Save changes
      </Button>
      <p role="status">{status}</p>
    </>
  );
}
  • It keeps its focus, because loading sets aria-disabled="true" instead of disabled. Disabling a focused button takes its focus away (Chrome moves it back to the <body>), and keyboard and screen reader users lose their place.
  • It keeps its size and name. The spinner covers the content, which stays in place but turns transparent, so nothing around the button moves and the accessible name stays the same.
  • It can't run twice. Clicks, Enter and Space are ignored until loading ends, and so is Enter in a form field when the button is the form's first submit button, which means no double submissions and no double charges.
  • It's announced. When loading starts, the Button announces its loadingLabel through a polite live region. Announcing the outcome is up to the page, with a status message like the one above, or an alert for errors (WCAG 4.1.3).

Toggle buttons

A toggle button switches something on and off, and tells assistive technologies which one it is with aria-pressed. A screen reader would announce it as "Mute, toggle button, pressed".

<Button
  variant="ghost"
  aria-pressed={muted}
  onClick={() => setMuted(!muted)}
  leading={muted ? <VolumeXIcon /> : <Volume2Icon />}
>
  Mute
</Button>

Keep the label the same in both states, so it says "Mute" whether it's pressed or not. If you'd rather change the label, like Play and Pause, leave aria-pressed out, since the label already tells people the state. For a setting that stays on or off, like "Email notifications", a Switch is a better fit.

A button that opens a menu says so with aria-haspopup="menu", and uses aria-expanded to say whether the menu is open. We recommend a trailing chevron, which gives sighted users the same hint, while the menu itself takes care of the arrow keys and focus, as described in the APG menu button pattern.

<Button
  variant="outline"
  aria-haspopup="menu"
  aria-expanded={open}
  onClick={() => setOpen(!open)}
  trailing={<ChevronDownIcon />}
>
  Options
</Button>

Adaptive

Pointer, touch and pen

  • Hover styles only apply to pointers that can actually hover (@media (hover: hover)), otherwise a tap would leave the button looking hovered.
  • On touch screens, every button gets a hit area of at least 44 × 44px, drawn by an invisible ::before, without changing how big it looks.
  • We recommend touch-action: manipulation, which stops a quick second tap from zooming the page, and a transparent -webkit-tap-highlight-color, which removes the grey flash on tap, since the button already draws its own pressed state.

Motion

When someone prefers reduced motion (prefers-reduced-motion: reduce), nothing turns or slides (WCAG 2.3.3). We recommend letting the spinner pulse instead of turning, so it still shows that the button is busy, which the Spinner does on its own.

More contrast

When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the borders of outline and subtle buttons are drawn in the label's colour at full strength, rather than a tint of it. The labels already pass 4.5:1, so they stay as they are.

Forced colours

Windows contrast themes replace every colour with the user's own, and drop backgrounds, gradients and shadows. To stay visible, the Button keeps a transparent border, which the system draws as its outline, draws focus with outline, and marks its states with system colours. We recommend GrayText when it's disabled and Highlight when it's pressed or expanded.

Colour scheme

The tokens use light-dark(), which follows color-scheme, so the Button adapts to your light and dark themes on its own. We've checked the contrast in both.

Text size and spacing

Heights are minimums in rem, so the button grows with its text instead of clipping it. Labels wrap at 200% text size (WCAG 1.4.4), with wider line, letter and word spacing (WCAG 1.4.12) and on screens as narrow as 320px (WCAG 1.4.10). A button's label should never be truncated.

Languages and direction

The Button uses logical properties and leading and trailing slots rather than left and right, so it mirrors itself in right-to-left languages. Translated labels are often much longer than English ones, which is one more reason to never give a button a fixed width. Remember to translate loadingLabel along with your labels, and to mark a label in another language with lang, so that screen readers pronounce it correctly (WCAG 3.1.2).

German, in a narrow column
Arabic, from right to left
<div className="w-40">
  <Button color="primary" lang="de">Alle Änderungen speichern</Button>
</div>

<div dir="rtl" lang="ar">
  <Button variant="outline" trailing={<ArrowRightIcon className="flip-in-rtl" />}>
    متابعة
  </Button>
</div>

The arrow above has a flip-in-rtl class, which mirrors it in right-to-left layouts. Give the same class to any other icon that points along the reading direction:

.flip-in-rtl:dir(rtl) {
  scale: -1 1;
}

Developer Experience (DX)

Next, let's look at how developers use the Button. It has one API, with the same names as every other component, and it's typed end to end.

import { Button } from "@/components/Button";

export function Settings() {
  return (
    <Button color="primary" onClick={save}>
      Save changes
    </Button>
  );
}

Consistent

Once you've learned the Button's API, you already know most of every other component's. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
variantPropThe emphasis, from solid down to link
colorPropThe intent, from primary to error
sizePropxs, sm, md, lg or xl
labelPropThe text content, when it's plain text
disabledPropThe component can't be used
loadingPropThe component is busy with an action
leading, trailingSlotsContent before and after the main content
clickEventThe component was activated

We recommend variant="solid", color="neutral" and size="md" as the defaults, while type always defaults to "button". We chose neutral so that a primary button is always a conscious decision, made once per view.

Type-safe

Props, events and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like variant="primary".

PropTypeDefaultDescription
variant"solid" | "outline" | "soft" | "subtle" | "ghost" | "link""solid"How much attention the button asks for
color"primary" | "secondary" | "neutral" | "success" | "info" | "warning" | "error""neutral"What the action means
size"xs" | "sm" | "md" | "lg" | "xl""md"How big the button is
labelstringThe label, when it's plain text. The default slot takes precedence over it
iconOnlybooleanfalseShows only the leading icon, while the label still names the button
type"button" | "submit" | "reset""button"The native button type
hrefstringRenders a link that looks like a button
disabledbooleanfalseBlocks activation
focusableWhenDisabledbooleanfalseKeeps a disabled button in the tab order
loadingbooleanfalseBlocks activation and shows a spinner, while keeping focus and size
loadingLabelstring"Loading"Announced to screen readers when loading starts
EventPayloadDescription
clickMouseEventFires when the button is activated, but never while it's disabled or loading
SlotDescription
defaultThe label
leadingAn icon before the label, which is also the icon of an icon-only button
trailingAn icon after the label

We also export ButtonProps, which comes in handy for wrappers and data-driven UIs:

import type { ButtonProps } from "@/components/Button";

const actions: (ButtonProps & { id: string })[] = [
  { id: "cancel", label: "Cancel", variant: "ghost" },
  { id: "publish", label: "Publish", color: "primary" },
];

Composable

The root element is the <button> (or the <a>) itself, so everything you pass lands directly on it. That includes classes, styles, id, ARIA attributes, event listeners and native attributes like name, value, form and popovertarget. A ref to the Button reaches that same element, so there's no wrapper to reach through.

<Button type="submit" name="intent" value="publish" color="primary">Publish</Button>
<Button popoverTarget="filters" aria-haspopup="dialog" variant="outline">Filters</Button>

Passing an href renders an <a>. For links that go through your router, give the Button the link's href and your router's click handler, which leaves modified clicks, like Ctrl-click to open a new tab, to the browser. Routers that handle every link on the page only need the href:

import { useHref, useLinkClickHandler } from "react-router";

export function SettingsLink() {
  const href = useHref("/settings");
  const navigate = useLinkClickHandler("/settings");

  return (
    <Button href={href} onClick={navigate}>
      Settings
    </Button>
  );
}

Since the Button doesn't know anything about your router, it works with any of them.

With other components

  • Tooltips on icon-only buttons repeat the button's label. The hidden label is what names the button, and the tooltip simply shows that name.
  • A Popover opens from a Button with popovertarget and aria-haspopup="dialog", like the Filters button above, and sets aria-expanded on it while it's open. Menus set aria-haspopup, aria-expanded and aria-controls on the Button, which passes them straight through.
  • The loading spinner is a Spinner. The Spinner draws itself, while the Button places it over the content, sizes it with --button--icon--size and announces loadingLabel, since the Spinner stays hidden from screen readers.
  • A group of buttons is simply a flex container with a gap. A toolbar, where the arrow keys move between buttons, is a separate pattern.

Controllable

The Button doesn't keep any state of its own. Every state is a prop or an attribute that you control, so all of them are always within reach:

StateHow you control it
Disableddisabled, and focusableWhenDisabled
Loadingloading, set when the action starts (see Loading)
Pressedaria-pressed, bound to whether it's on
Expandedaria-expanded, bound to whether its popup is open
FocusA ref to its element, and focus()

Agentic Experience (AX)

Finally, let's look at how agents read, build and check a button. They rely on the same accessibility tree as assistive technologies, and on a contract they can check the code against.

Semantic

Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names and states. This is how they see the examples on this page:

- button "Cancel"
- button "Schedule…"
- button "Publish post"
- button "Save" [disabled]
- button "Mute" [pressed]
- button "Options" [expanded]
- button "Delete"
- link "Read the introduction":
  - /url: /docs

A <div> with a click handler has no role, so neither agents nor screen readers see it as a button. A real button has a role, a name and states, so an agent can tell what pressing it will do, and find it by its name:

await page.getByRole("button", { name: "Save changes" }).click();

Described

Here's the whole contract in one block, for agents to read before they build or review a button:

component: Button
summary: Runs an action in place. Renders a link when given an href.
standard: https://opencomponents.dev/docs/components/button
element: button # a, with href
role: button # link, with href
props:
  variant: { type: [solid, outline, soft, subtle, ghost, link], default: solid }
  color: { type: [primary, secondary, neutral, success, info, warning, error], default: neutral }
  size: { type: [xs, sm, md, lg, xl], default: md }
  label: { type: string }
  iconOnly: { type: boolean, default: false }
  type: { type: [button, submit, reset], default: button }
  href: { type: string }
  disabled: { type: boolean, default: false }
  focusableWhenDisabled: { type: boolean, default: false }
  loading: { type: boolean, default: false }
  loadingLabel: { type: string, default: Loading }
slots:
  default: The label.
  leading: An icon before the label, and the icon of an icon-only button.
  trailing: An icon after the label.
events:
  click: MouseEvent. Never fires while disabled or loading.
states:
  hover: ":hover, in @media (hover: hover)"
  active: ":active"
  focus: ":focus-visible"
  disabled: "[disabled], or [aria-disabled=true] when focusableWhenDisabled"
  loading: "[aria-disabled=true][data-loading]"
  pressed: "[aria-pressed=true]"
  expanded: "[aria-expanded=true]"
keyboard:
  Enter: Activates it. Links too.
  Space: Activates it, on release. Buttons only.
parts: [leading, label, trailing, spinner] # data-slot, in this order. The spinner is a Spinner: https://opencomponents.dev/docs/components/spinner
tokens:
  theme: ["--color--{color}", "--color--{color}-contrast", "--color--{color}-text", "--color--focus"]
  variables: [--button--height, --button--padding, --button--gap, --button--font-size, --button--icon--size, --button--radius]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/button#checklist

It's also published on its own at /raw/docs/components/button.yaml, with every rule from the checklist in place of the rules link. It's about a tenth of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them.

Every rule has a stable ID, like button/keep-focus, so you and your agents can cite it in reviews and commits.

Deterministic

The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's a primary Button that's loading:

<Button color="primary" loading>Save</Button>

And here's what it renders:

<button
  class="button"
  type="button"
  aria-disabled="true"
  data-variant="solid"
  data-color="primary"
  data-size="md"
  data-loading=""
>
  <span data-slot="label">Save</span>
  <svg class="spinner" viewBox="0 0 16 16" aria-hidden="true" data-slot="spinner">…</svg>
</button>
  • Props are mirrored as data-* attributes. data-variant, data-color and data-size are always there, while data-icon-only and data-loading only appear when they're set.
  • Parts are marked with data-slot and always come in the same order: leading, label, trailing and spinner, which is a Spinner.
  • States change attributes, never elements. The focused element is never replaced, so both focus and the screen reader's position survive every state change.
  • There's nothing random or generated, like IDs or timestamps, so the markup is the same on the server, on the client and in every snapshot.
  • Icon-only is always a prop, and never guessed from the content.

Verifiable

Every rule in the checklist can be checked, and about half of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in Button.test.ts, which you'll find under Reference implementation. We run them on every change to this page, so the code you see always passes them.

In the browser

In the browser, you can run axe to check names, contrast, target size and nesting, and use an ARIA snapshot to check what agents will see:

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("the publish dialog's buttons", async ({ page }) => {
  await page.goto("/posts/new");

  await expect(page.getByRole("dialog")).toMatchAriaSnapshot(`
    - button "Cancel"
    - button "Schedule…"
    - button "Publish post"
  `);

  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(violations).toEqual([]);
});

Some things still need a human, so try the button with the keyboard alone, with a screen reader and at 200% zoom, and with forced colours, more contrast and reduced motion turned on. You can emulate all three in Chrome DevTools, under Rendering.

During development

Our reference implementation also warns you in the console as soon as a button renders without an accessible name, which is the most common button bug out there.

Prompts

You can point your agent straight at this page. To build a button, you could use:

Build a Button component for our React design system that meets the Open Components
Button standard: https://opencomponents.dev/raw/docs/components/button.md

Follow its API, its DOM contract and its tokens. Port its tests and make them pass.
Then check your work against every rule in its checklist with a Component or Both
scope, and cite the rule ID for any rule you can't meet.

To review one, the contract is enough:

Review our Button against the Open Components Button contract:
https://opencomponents.dev/raw/docs/components/button.yaml

For each rule with a component or both scope, report pass or fail with the rule ID
and the evidence from our code. Then fix the failures.

And to review how a screen uses its buttons:

Review how our checkout page uses its buttons against the Open Components Button
contract: https://opencomponents.dev/raw/docs/components/button.yaml

For each rule with a usage or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A button meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Button itself, so they're the ones to check when you build or review one. Usage rules are met by the screens that use it, like keeping one primary action per view, so they're the ones to check when you review a screen. Both rules need the two to work together, like naming an icon-only button: the Button keeps its label as hidden text, as long as you give it one.

The basis says where a rule comes from. About half the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and a few come from HTML, WAI-ARIA or the APG. The rest are Open Components rules, which are our own, like never changing a button's size between states. Where a rule goes further than WCAG asks at level AA, its basis says so: some come from level AAA, like the 44 × 44px hit area on touch screens, and a rule that asks for more than its criterion says beyond, like the underline on link buttons, which WCAG would let you swap for a 3:1 contrast with the text around it.

UI rules

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

UX rules

RuleLevelScopeRequirementBasisCheck
button/native-elementMustComponentIt's a native <button>, or an <a href> for navigation, but never a <div> or a <span>Open Components, beyond WCAG 2.1.1, 4.1.2 (A)Unit test
button/links-navigateMustUsageNavigation always uses a link, so a button never changes the URLOpen ComponentsReview
button/accessible-nameMustBothEvery button has a name, and an icon-only button has a visually hidden labelWCAG 4.1.2 (A), Open Componentsaxe button-name, unit test
button/label-in-nameMustUsageAn aria-label or aria-labelledby includes the visible label, ideally at the startWCAG 2.5.3 (A)axe label-content-name-mismatch
button/decorative-iconsMustComponentIcons are hidden from assistive technologies with aria-hidden, so they don't add to the button's nameWCAG 1.1.1 (A)Unit test
button/verb-labelsShouldUsageLabels start with a verb and name the outcome, and end with … when more input followsOpen ComponentsReview
button/keyboardMustComponentEnter and Space activate a button, while Enter follows a linkWCAG 2.1.1 (A), APGUnit test
button/activate-on-clickMustUsageActions run on click, never on mousedown, pointerdown or touchstartOpen Components, beyond WCAG 2.5.2 (A)Review
button/no-nestingMustUsageThere's nothing interactive inside a button, and no button inside a linkHTMLaxe nested-interactive
button/dom-orderMustUsageButtons come in the same order in the DOM as on screen, without order or row-reverseOpen Components, beyond WCAG 1.3.2, 2.4.3 (A)Keyboard
button/disabled-inertMustComponentA disabled or loading button does nothing, so there's no event, no submission and no navigationHTML, WAI-ARIAUnit test
button/explain-disabledShouldUsageAn enabled button with validation is preferred over a disabled one, and a disabled one explains whyOpen ComponentsReview
button/keep-focusMustBothDisabling a button never takes its focus away: loading uses aria-disabled, and a button that can be disabled while it has focus, like Next on the last page, stays focusableOpen ComponentsUnit test, keyboard
button/loadingMustComponentLoading keeps the button's size and name, blocks repeated activation and is announcedWCAG 4.1.3 (AA), Open ComponentsUnit test
button/announce-outcomeShouldUsageThe page announces the action's outcome with a status messageOpen Components, beyond WCAG 4.1.3 (AA)Screen reader
button/toggle-pressedMustBothA toggle button sets aria-pressed and keeps its labelWCAG 4.1.2 (A), APGUnit test
button/popup-expandedMustBothA button that opens a popup sets aria-haspopup and aria-expandedWCAG 4.1.2 (A), APGUnit test
button/focus-afterShouldUsageAfter activation, focus moves as the APG describes, and never falls back to the top of the pageAPGKeyboard
button/focus-not-obscuredMustUsageSticky headers and footers never cover a focused button, for example thanks to scroll-paddingWCAG 2.4.11 (AA), 2.4.12 (AAA)Keyboard
button/destructiveShouldUsageA destructive action uses error, names what it destroys, and can either be undone or asks for confirmationOpen Components, beyond WCAG 3.3.4 (AA)Review
button/mirrors-in-rtlShouldBothIt uses logical properties, and icons that point along the reading direction flip in right-to-left layoutsOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
button/shared-vocabularyMustComponentIt uses the shared names: variant, color, size, label, disabled, loading, leading, trailing and clickOpen ComponentsReview
button/typedMustComponentProps, events and slots are typed, with unions rather than stringOpen ComponentsType check
button/explicit-typeMustBothtype defaults to "button", and submit buttons say type="submit" explicitlyOpen ComponentsUnit test
button/root-elementMustComponentThe root element is the button itself, so attributes, listeners and refs land on itOpen ComponentsUnit test
button/statelessShouldComponentEvery state is a prop or an attribute that the parent controlsOpen ComponentsReview

AX rules

RuleLevelScopeRequirementBasisCheck
button/role-and-nameMustComponentIt can be found by its role and name alone, with getByRole("button", { name })WCAG 4.1.2 (A)Unit test
button/deterministic-domMustComponentThe same props render the same markup, and states change attributes, never elementsOpen ComponentsUnit test
button/data-attributesShouldComponentProps are mirrored as data-* attributes, and parts are marked with data-slotOpen ComponentsUnit test
button/dev-warningsShouldComponentIt warns during development when a button has no accessible nameOpen ComponentsUnit test

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It shows the Spinner while it's loading, so copy that too. Every component that announces something to screen readers shares announce.ts, so you only need one copy of it, and tokens.css goes in your theme.

The tests are in Button.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.

<script setup lang="ts">
import { computed, onMounted, useTemplateRef, watch } from "vue";
import Spinner from "../spinner/Spinner.vue";
import { announce, prepareAnnouncer } from "./announce";

export interface ButtonProps {
  /** How much attention the button asks for. */
  variant?: "solid" | "outline" | "soft" | "subtle" | "ghost" | "link";
  /** What the action means. */
  color?: "primary" | "secondary" | "neutral" | "success" | "info" | "warning" | "error";
  size?: "xs" | "sm" | "md" | "lg" | "xl";
  /** The label, when it's plain text. The default slot takes precedence. */
  label?: string;
  /** Shows only the leading icon. The label is hidden, but it still names the button. */
  iconOnly?: boolean;
  /** "button" by default, so the button never submits a form by accident. */
  type?: "button" | "submit" | "reset";
  /** Renders a link (`<a href>`) that looks like a button, for navigation. */
  href?: string;
  disabled?: boolean;
  /** Keeps the disabled button in the tab order, so people can find it and learn why. */
  focusableWhenDisabled?: boolean;
  /** Blocks activation and shows a spinner, keeping the button's focus and size. */
  loading?: boolean;
  /** Announced to screen readers when loading starts. */
  loadingLabel?: string;
}

const {
  variant = "solid",
  color = "neutral",
  size = "md",
  label,
  iconOnly = false,
  type = "button",
  href,
  disabled = false,
  focusableWhenDisabled = false,
  loading = false,
  loadingLabel = "Loading",
} = defineProps<ButtonProps>();

const emit = defineEmits<{
  /** Not emitted while the button is disabled or loading. */
  click: [event: MouseEvent];
}>();

defineSlots<{
  /** The label. */
  default?: () => unknown;
  /** An icon before the label, which is also the icon of an icon-only button. */
  leading?: () => unknown;
  /** An icon after the label. */
  trailing?: () => unknown;
}>();

// The button can't be activated while it's disabled or loading.
const inactive = computed(() => disabled || loading);
// A loading button was just pressed, so it keeps its focus, which native
// `disabled` would drop. Links can't be natively disabled at all.
const focusable = computed(() => loading || focusableWhenDisabled);
const nativeDisabled = computed(() => disabled && !focusable.value && !href);

function onClick(event: MouseEvent) {
  if (inactive.value) {
    // aria-disabled doesn't stop activation on its own, so cancel the form
    // submission or navigation, and keep the click from reaching other listeners.
    event.preventDefault();
    event.stopImmediatePropagation();
    return;
  }
  emit("click", event);
}

watch(
  () => loading,
  (isLoading) => {
    if (isLoading) announce(loadingLabel);
  },
);

const root = useTemplateRef<HTMLElement>("root");

onMounted(() => {
  prepareAnnouncer();

  if (import.meta.env.DEV) {
    const el = root.value;
    const named = el?.textContent?.trim() || el?.hasAttribute("aria-label") || el?.hasAttribute("aria-labelledby");
    if (el && !named) {
      console.warn("[Button] has no accessible name: give it a label, even when icon-only.", el);
    }
  }
});
</script>

<template>
  <component
    :is="href ? 'a' : 'button'"
    ref="root"
    class="button"
    :type="href ? undefined : type"
    :href="href && !inactive ? href : undefined"
    :role="href && inactive ? 'link' : undefined"
    :tabindex="href && inactive && focusable ? 0 : undefined"
    :disabled="nativeDisabled"
    :aria-disabled="(inactive && !nativeDisabled) || undefined"
    :data-variant="variant"
    :data-color="color"
    :data-size="size"
    :data-icon-only="iconOnly ? '' : undefined"
    :data-loading="loading ? '' : undefined"
    @click="onClick"
  >
    <span v-if="$slots.leading" data-slot="leading" aria-hidden="true">
      <slot name="leading" />
    </span>
    <span v-if="$slots.default || label" data-slot="label">
      <slot>{{ label }}</slot>
    </span>
    <span v-if="$slots.trailing && !iconOnly" data-slot="trailing" aria-hidden="true">
      <slot name="trailing" />
    </span>
    <Spinner v-if="loading" data-slot="spinner" />
  </component>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The button's styles go in `components`,
   so they win over the resets in `base`, and give way to utilities and to any
   style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;

@layer components {
  .button {
    /* Set per size. */
    --button--height: 2.25rem;
    --button--padding: 1rem;
    --button--gap: 0.5rem;
    --button--font-size: 0.875rem;
    --button--icon--size: 1rem;
    --button--radius: 0.5rem;
    /* Set per colour, from the theme tokens. */
    --button--fill: var(--color--neutral);
    --button--fill-contrast: var(--color--neutral-contrast);
    --button--text: var(--color--neutral-text);
    /* The state layer lays the label's colour over the background (8% on hover, 12% when pressed). */
    --button--state: 0%;

    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--button--gap);
    box-sizing: border-box;
    /* A min-height rather than a height, so the button grows with larger text and wrapped labels. */
    min-height: var(--button--height);
    min-width: var(--button--height);
    padding: 0.125rem var(--button--padding);
    margin: 0;
    /* Transparent, until forced colors mode draws it as the button's outline. */
    border: 1px solid transparent;
    border-radius: var(--button--radius);
    background-color: transparent;
    background-image: linear-gradient(
      color-mix(in srgb, currentColor var(--button--state), transparent) 0 0
    );
    font-family: inherit;
    font-size: var(--button--font-size);
    font-weight: 500;
    line-height: 1.25;
    text-align: center;
    text-decoration: none;
    vertical-align: middle;
    cursor: pointer;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
  }

  /* Sizes */
  .button[data-size="xs"] {
    --button--height: 1.5rem;
    --button--padding: 0.5rem;
    --button--gap: 0.25rem;
    --button--font-size: 0.75rem;
    --button--icon--size: 0.875rem;
    --button--radius: 0.375rem;
  }
  .button[data-size="sm"] {
    --button--height: 2rem;
    --button--padding: 0.75rem;
    --button--radius: 0.375rem;
  }
  .button[data-size="lg"] {
    --button--height: 2.5rem;
    --button--padding: 1.25rem;
    --button--font-size: 1rem;
    --button--icon--size: 1.25rem;
  }
  .button[data-size="xl"] {
    --button--height: 3rem;
    --button--padding: 1.5rem;
    --button--font-size: 1rem;
    --button--icon--size: 1.25rem;
    --button--radius: 0.75rem;
  }

  /* Colours */
  .button[data-color="primary"] {
    --button--fill: var(--color--primary);
    --button--fill-contrast: var(--color--primary-contrast);
    --button--text: var(--color--primary-text);
  }
  .button[data-color="secondary"] {
    --button--fill: var(--color--secondary);
    --button--fill-contrast: var(--color--secondary-contrast);
    --button--text: var(--color--secondary-text);
  }
  .button[data-color="success"] {
    --button--fill: var(--color--success);
    --button--fill-contrast: var(--color--success-contrast);
    --button--text: var(--color--success-text);
  }
  .button[data-color="info"] {
    --button--fill: var(--color--info);
    --button--fill-contrast: var(--color--info-contrast);
    --button--text: var(--color--info-text);
  }
  .button[data-color="warning"] {
    --button--fill: var(--color--warning);
    --button--fill-contrast: var(--color--warning-contrast);
    --button--text: var(--color--warning-text);
  }
  .button[data-color="error"] {
    --button--fill: var(--color--error);
    --button--fill-contrast: var(--color--error-contrast);
    --button--text: var(--color--error-text);
  }

  /* Variants, from the most emphasis to the least */
  .button[data-variant="solid"] {
    background-color: var(--button--fill);
    color: var(--button--fill-contrast);
  }
  .button[data-variant="outline"] {
    border-color: color-mix(in srgb, currentColor 40%, transparent);
    color: var(--button--text);
  }
  .button[data-variant="soft"],
  .button[data-variant="subtle"] {
    background-color: color-mix(in srgb, var(--button--fill) 12%, transparent);
    color: var(--button--text);
  }
  .button[data-variant="subtle"] {
    border-color: color-mix(in srgb, currentColor 25%, transparent);
  }
  .button[data-variant="ghost"] {
    color: var(--button--text);
  }
  .button[data-variant="link"] {
    min-width: 0;
    padding-inline: 0;
    background-image: none;
    color: var(--button--text);
    /* Underlined at rest, so it doesn't rely on colour alone to stand out from the text around it. */
    text-decoration-line: underline;
    text-underline-offset: 0.25em;
  }

  /* States. Each one reads the attribute that exposes it, so what people see and
     what assistive technologies report can't drift apart. */
  @media (hover: hover) {
    .button:hover {
      --button--state: 8%;
    }
    .button[data-variant="link"]:hover:not(:disabled, [aria-disabled="true"]) {
      text-decoration-thickness: 2px;
    }
  }
  .button:active,
  .button[aria-pressed="true"],
  .button[aria-expanded="true"] {
    --button--state: 12%;
  }
  .button:focus-visible {
    outline: 2px solid var(--color--focus);
    outline-offset: 2px;
  }
  .button:disabled,
  .button[aria-disabled="true"] {
    --button--state: 0%;
    cursor: not-allowed;
  }
  .button:disabled:not([data-loading]),
  .button[aria-disabled="true"]:not([data-loading]) {
    opacity: 0.5;
  }
  .button[data-loading] {
    cursor: progress;
  }

  /* Parts */
  [data-slot="leading"],
  [data-slot="trailing"] {
    display: inline-flex;
    flex-shrink: 0;
    width: var(--button--icon--size);
    height: var(--button--icon--size);
  }
  [data-slot="leading"] > :slotted(*),
  [data-slot="trailing"] > :slotted(*) {
    width: 100%;
    height: 100%;
  }
  .button[data-icon-only] {
    padding-inline: 0;
  }

  /* While loading, the spinner covers the content, which stays in place so the
     size doesn't change, and in the accessibility tree so the name doesn't either. */
  .button[data-loading] > :not([data-slot="spinner"]) {
    opacity: 0;
  }
  /* The Spinner turns, and pulses with reduced motion, while the Button places and
     sizes it. `.button >` outranks the Spinner's own `.spinner`, whichever loads first. */
  .button > [data-slot="spinner"] {
    --spinner--size: var(--button--icon--size);

    position: absolute;
    inset: 0;
    margin: auto;
  }

  /* When the button is icon-only, the label is visually hidden, but it still names the button. */
  .button[data-icon-only] > [data-slot="label"] {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* On touch screens, every size gets a hit area of at least 44 × 44px. */
  @media (any-pointer: coarse) {
    .button::before {
      content: "";
      position: absolute;
      top: 50%;
      left: 50%;
      width: max(100%, 2.75rem);
      height: max(100%, 2.75rem);
      translate: -50% -50%;
    }
  }

  /* With more contrast, borders are drawn in the label's colour at full strength. */
  @media (prefers-contrast: more) {
    .button[data-variant="outline"],
    .button[data-variant="subtle"] {
      border-color: currentColor;
    }
  }

  /* Forced colors mode drops backgrounds and gradients, so mark states with system colours. */
  @media (forced-colors: active) {
    .button:focus-visible {
      outline-color: Highlight;
    }
    .button[aria-pressed="true"],
    .button[aria-expanded="true"] {
      /* We set a system colour pair ourselves, so opt out, or the browser puts a
         Canvas backplate behind the label. */
      forced-color-adjust: none;
      background-color: Highlight;
      color: HighlightText;
    }
    .button:disabled,
    .button[aria-disabled="true"] {
      border-color: GrayText;
      color: GrayText;
      opacity: 1;
    }
    .button[data-variant="link"] {
      padding-inline: 0.25rem;
    }
  }
}
</style>

FAQ

Buttons do things, while links go places. If people might want to open it in a new tab, bookmark it or share it, it's a link.

When you activate it, it…UseRenders
Runs an action on the page, like saving, sending or deletingButton<button type="button">
Submits a formButton, with type="submit"<button type="submit">
Takes you to another page, a section or a fileButton, with href<a href>
Switches something on or offToggle button<button aria-pressed>
Opens a menuMenu button<button aria-haspopup="menu" aria-expanded>

Links come with plenty of browser features for free, such as opening in a new tab, copying the address or showing up in the history. Screen readers also list them together with the other links on the page, and pressing Space scrolls the page instead of following the link. A button that navigates loses all of that, which is why our Button renders a link as soon as you give it an href:

<Button href="/docs" color="primary" trailing={<ArrowRightIcon />}>
  Read the introduction
</Button>
{/* Do: use a link for navigation, even when it looks like a button */}
<Button href="/pricing" color="primary">See pricing</Button>

{/* Don't: this can't be opened in a new tab, and screen readers won't list it as a link */}
<Button color="primary" onClick={() => navigate("/pricing")}>See pricing</Button>

Sources

Copyright © 2026