Button

User Experience (UX)

How a button behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.

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

UX Checklist

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

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>
Copyright © 2026