# 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](/docs/components/button#reference-implementation), which does its part of every rule in the [checklist](/docs/components/button#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](/docs/components/button#deterministic).

::button-playground
::

## At a glance

| Aspect   | Button                                                                                       |
| -------- | -------------------------------------------------------------------------------------------- |
| Element  | `<button type="button">`, or `<a href>` when it takes you somewhere                          |
| Role     | `button`, or `link` when it has an `href`                                                    |
| Name     | Its label, which stays in the page as hidden text when the button is icon-only               |
| Keyboard | `Enter` and `Space` activate it, while links only respond to `Enter`                         |
| States   | Hover, active, focus, disabled, loading, pressed and expanded                                |
| Variants | `solid`, `outline`, `soft`, `subtle`, `ghost` and `link`                                     |
| Colours  | `primary`, `secondary`, `neutral`, `success`, `info`, `warning` and `error`                  |
| Sizes    | `xs`, `sm`, `md`, `lg` and `xl`                                                              |
| Pattern  | [WAI-ARIA APG Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/)                      |
| WCAG 2.2 | 1.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

::button-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](/docs/components/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](/docs/components/button#deterministic).

### 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-variants-example
::framework-switcher
#react
```tsx
<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>
```

#vue
```vue
<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>
```

#svelte
```svelte
<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>
```

#angular
```angular-html
<button appButton color="primary">Solid</button>
<button appButton color="primary" variant="outline">Outline</button>
<button appButton color="primary" variant="soft">Soft</button>
<button appButton color="primary" variant="subtle">Subtle</button>
<button appButton color="primary" variant="ghost">Ghost</button>
<button appButton color="primary" variant="link">Link</button>
```

#solid
```tsx
<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>
```

#astro
```astro
<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>
```

#vanilla
```html
<button class="button" type="button" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Solid</span>
</button>
<button class="button" type="button" data-variant="outline" data-color="primary" data-size="md">
  <span data-slot="label">Outline</span>
</button>
<button class="button" type="button" data-variant="soft" data-color="primary" data-size="md">
  <span data-slot="label">Soft</span>
</button>
<button class="button" type="button" data-variant="subtle" data-color="primary" data-size="md">
  <span data-slot="label">Subtle</span>
</button>
<button class="button" type="button" data-variant="ghost" data-color="primary" data-size="md">
  <span data-slot="label">Ghost</span>
</button>
<button class="button" type="button" data-variant="link" data-color="primary" data-size="md">
  <span data-slot="label">Link</span>
</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](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html)), 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](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html)).

::button-colors-example
::framework-switcher
#react
```tsx
<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. */}
```

#vue
```vue
<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. -->
```

#svelte
```svelte
<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. -->
```

#angular
```angular-html
<button appButton color="primary">Solid</button>
<button appButton color="secondary">Solid</button>
<button appButton color="neutral">Solid</button>
<button appButton color="success">Solid</button>
<button appButton color="info">Solid</button>
<button appButton color="warning">Solid</button>
<button appButton color="error">Solid</button>
<!-- Every colour works with every variant. -->
```

#solid
```tsx
<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. */}
```

#astro
```astro
<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. -->
```

#vanilla
```html
<button class="button" type="button" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Solid</span>
</button>
<button class="button" type="button" data-variant="solid" data-color="secondary" data-size="md">
  <span data-slot="label">Solid</span>
</button>
<button class="button" type="button" data-variant="solid" data-color="neutral" data-size="md">
  <span data-slot="label">Solid</span>
</button>
<button class="button" type="button" data-variant="solid" data-color="success" data-size="md">
  <span data-slot="label">Solid</span>
</button>
<button class="button" type="button" data-variant="solid" data-color="info" data-size="md">
  <span data-slot="label">Solid</span>
</button>
<button class="button" type="button" data-variant="solid" data-color="warning" data-size="md">
  <span data-slot="label">Solid</span>
</button>
<button class="button" type="button" data-variant="solid" data-color="error" data-size="md">
  <span data-slot="label">Solid</span>
</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](/docs/components/button#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-sizes-example
::framework-switcher
#react
```tsx
<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 */}
```

#vue
```vue
<Button size="xs" color="primary" variant="soft">
  <template #leading><PlusIcon /></template>
  Add xs
</Button>
<!-- …sm, md, lg, xl -->

<Button size="xs" label="Settings" variant="outline" icon-only>
  <template #leading><SettingsIcon /></template>
</Button>
<!-- …sm, md, lg, xl -->
```

#svelte
```svelte
<Button size="xs" color="primary" variant="soft">
  {#snippet leading()}<PlusIcon />{/snippet}
  Add xs
</Button>
<!-- …sm, md, lg, xl -->

<Button size="xs" label="Settings" variant="outline" iconOnly>
  {#snippet leading()}<SettingsIcon />{/snippet}
</Button>
<!-- …sm, md, lg, xl -->
```

#angular
```angular-html
<button appButton size="xs" color="primary" variant="soft">
  <lucide-icon leading [img]="PlusIcon" />
  Add xs
</button>
<!-- …sm, md, lg, xl -->

<button appButton size="xs" label="Settings" variant="outline" iconOnly>
  <lucide-icon leading [img]="SettingsIcon" />
</button>
<!-- …sm, md, lg, xl -->
```

#solid
```tsx
<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 */}
```

#astro
```astro
<Button size="xs" color="primary" variant="soft">
  <PlusIcon slot="leading" />
  Add xs
</Button>
<!-- …sm, md, lg, xl -->

<Button size="xs" label="Settings" variant="outline" iconOnly>
  <SettingsIcon slot="leading" />
</Button>
<!-- …sm, md, lg, xl -->
```

#vanilla
```html
<button class="button" type="button" data-variant="soft" data-color="primary" data-size="xs">
  <span data-slot="leading" aria-hidden="true"><svg class="lucide-plus">…</svg></span>
  <span data-slot="label">Add xs</span>
</button>
<!-- …sm, md, lg, xl -->

<button class="button" type="button" data-variant="outline" data-color="neutral" data-size="xs" data-icon-only>
  <span data-slot="leading" aria-hidden="true"><svg class="lucide-settings">…</svg></span>
  <span data-slot="label">Settings</span>
</button>
<!-- …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](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html), 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](https://lucide.dev)'s, like `PlusIcon`) and place the icons in the `leading` and `trailing` slots.

::button-icons-example
::framework-switcher
#react
```tsx
<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 />} />
```

#vue
```vue
<Button color="primary">
  <template #leading><PlusIcon /></template>
  New project
</Button>

<Button variant="outline">
  Continue
  <template #trailing><ArrowRightIcon /></template>
</Button>

<Button label="Delete" variant="ghost" color="error" icon-only>
  <template #leading><TrashIcon /></template>
</Button>
```

#svelte
```svelte
<Button color="primary">
  {#snippet leading()}<PlusIcon />{/snippet}
  New project
</Button>

<Button variant="outline">
  Continue
  {#snippet trailing()}<ArrowRightIcon />{/snippet}
</Button>

<Button label="Delete" variant="ghost" color="error" iconOnly>
  {#snippet leading()}<TrashIcon />{/snippet}
</Button>
```

#angular
```angular-html
<button appButton color="primary">
  <lucide-icon leading [img]="PlusIcon" />
  New project
</button>

<button appButton variant="outline">
  Continue
  <lucide-icon trailing [img]="ArrowRightIcon" />
</button>

<button appButton label="Delete" variant="ghost" color="error" iconOnly>
  <lucide-icon leading [img]="TrashIcon" />
</button>
```

#solid
```tsx
<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 />} />
```

#astro
```astro
<Button color="primary">
  <PlusIcon slot="leading" />
  New project
</Button>

<Button variant="outline">
  Continue
  <ArrowRightIcon slot="trailing" />
</Button>

<Button label="Delete" variant="ghost" color="error" iconOnly>
  <TrashIcon slot="leading" />
</Button>
```

#vanilla
```html
<button class="button" type="button" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="leading" aria-hidden="true"><svg class="lucide-plus">…</svg></span>
  <span data-slot="label">New project</span>
</button>

<button class="button" type="button" data-variant="outline" data-color="neutral" data-size="md">
  <span data-slot="label">Continue</span>
  <span data-slot="trailing" aria-hidden="true"><svg class="lucide-arrow-right">…</svg></span>
</button>

<button class="button" type="button" data-variant="ghost" data-color="error" data-size="md" data-icon-only>
  <span data-slot="leading" aria-hidden="true"><svg class="lucide-trash">…</svg></span>
  <span data-slot="label">Delete</span>
</button>
```
::
::

- 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-states-example
::framework-switcher
#react
```tsx
<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>
```

#vue
```vue
<Button color="primary">Save</Button>
<Button color="primary" disabled>Save</Button>
<Button color="primary" disabled focusable-when-disabled>Save</Button>
<Button color="primary" loading loading-label="Saving">Save</Button>

<Button variant="ghost" :aria-pressed="muted" @click="muted = !muted">
  <template #leading><VolumeXIcon v-if="muted" /><Volume2Icon v-else /></template>
  Mute
</Button>

<Button variant="outline" aria-haspopup="menu" :aria-expanded="open" @click="open = !open">
  Options
  <template #trailing><ChevronDownIcon /></template>
</Button>
```

#svelte
```svelte
<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={() => (muted = !muted)}>
  {#snippet leading()}
    {#if muted}<VolumeXIcon />{:else}<Volume2Icon />{/if}
  {/snippet}
  Mute
</Button>

<Button variant="outline" aria-haspopup="menu" aria-expanded={open} onclick={() => (open = !open)}>
  Options
  {#snippet trailing()}<ChevronDownIcon />{/snippet}
</Button>
```

#angular
```angular-html
<button appButton color="primary">Save</button>
<button appButton color="primary" disabled>Save</button>
<button appButton color="primary" disabled focusableWhenDisabled>Save</button>
<button appButton color="primary" loading loadingLabel="Saving">Save</button>

<button appButton variant="ghost" [attr.aria-pressed]="muted()" (click)="muted.set(!muted())">
  <lucide-icon leading [img]="muted() ? VolumeXIcon : Volume2Icon" />
  Mute
</button>

<button
  appButton
  variant="outline"
  aria-haspopup="menu"
  [attr.aria-expanded]="open()"
  (click)="open.set(!open())"
>
  Options
  <lucide-icon trailing [img]="ChevronDownIcon" />
</button>
```

#solid
```tsx
<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>
```

#astro
```astro
<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>

<!-- A script flips aria-pressed and aria-expanded on click, as under Toggle buttons and Menu buttons. -->
<Button variant="ghost" aria-pressed="false">
  <VolumeXIcon slot="leading" />
  Mute
</Button>

<Button variant="outline" aria-haspopup="menu" aria-expanded="false">
  Options
  <ChevronDownIcon slot="trailing" />
</Button>
```

#vanilla
```html
<!-- aria-disabled doesn't block activation on its own, so click handlers check it, as under Loading. -->
<button class="button" type="button" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Save</span>
</button>
<button class="button" type="button" disabled data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Save</span>
</button>
<button class="button" type="button" aria-disabled="true" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Save</span>
</button>
<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>

<!-- A script flips aria-pressed and aria-expanded on click, as under Toggle buttons and Menu buttons. -->
<button class="button" type="button" aria-pressed="false" data-variant="ghost" data-color="neutral" data-size="md">
  <span data-slot="leading" aria-hidden="true"><svg class="lucide-volume-x">…</svg></span>
  <span data-slot="label">Mute</span>
</button>

<button class="button" type="button" aria-haspopup="menu" aria-expanded="false" data-variant="outline" data-color="neutral" data-size="md">
  <span data-slot="label">Options</span>
  <span data-slot="trailing" aria-hidden="true"><svg class="lucide-chevron-down">…</svg></span>
</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](https://m3.material.io/foundations/interaction/states/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](/docs/components/button#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-hierarchy-example
::framework-switcher
#react
```tsx
<Button variant="ghost">Cancel</Button>
<Button variant="outline">Schedule…</Button>
<Button color="primary">Publish post</Button>
```

#vue
```vue
<Button variant="ghost">Cancel</Button>
<Button variant="outline">Schedule…</Button>
<Button color="primary">Publish post</Button>
```

#svelte
```svelte
<Button variant="ghost">Cancel</Button>
<Button variant="outline">Schedule…</Button>
<Button color="primary">Publish post</Button>
```

#angular
```angular-html
<button appButton variant="ghost">Cancel</button>
<button appButton variant="outline">Schedule…</button>
<button appButton color="primary">Publish post</button>
```

#solid
```tsx
<Button variant="ghost">Cancel</Button>
<Button variant="outline">Schedule…</Button>
<Button color="primary">Publish post</Button>
```

#astro
```astro
<Button variant="ghost">Cancel</Button>
<Button variant="outline">Schedule…</Button>
<Button color="primary">Publish post</Button>
```

#vanilla
```html
<button class="button" type="button" data-variant="ghost" data-color="neutral" data-size="md">
  <span data-slot="label">Cancel</span>
</button>
<button class="button" type="button" data-variant="outline" data-color="neutral" data-size="md">
  <span data-slot="label">Schedule…</span>
</button>
<button class="button" type="button" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Publish post</span>
</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](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html), [2.4.3](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html)).
- 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](/docs/foundations/design-tokens#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.

```css
: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](/docs/foundations/design-tokens#component-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:

::framework-switcher
#react
```tsx [GetStarted.tsx]
import styles from "./GetStarted.module.css";

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

#vue
```vue
<template>
  <Button color="primary" class="pill">Get started</Button>
</template>

<style scoped>
.pill {
  --button--radius: 999px;
}
</style>
```

#svelte
```svelte
<Button color="primary" class="pill">Get started</Button>

<style>
  /* The class lands on the Button's own element, which this component's scoped styles don't reach. */
  :global(.pill) {
    --button--radius: 999px;
  }
</style>
```

#angular
```angular-ts
@Component({
  selector: "app-get-started",
  imports: [Button],
  template: `<button appButton color="primary" class="pill">Get started</button>`,
  styles: `
    .pill {
      --button--radius: 999px;
    }
  `,
})
export class GetStarted {}
```

#solid
```tsx [GetStarted.tsx]
import styles from "./GetStarted.module.css";

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

#astro
```astro
<Button color="primary" class="pill">Get started</Button>

<style>
  .pill {
    --button--radius: 999px;
  }
</style>
```

#vanilla
```html
<button class="button pill" type="button" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Get started</span>
</button>

<style>
  .pill {
    --button--radius: 999px;
  }
</style>
```
::

## User Experience (UX)

Now let's look at how a button behaves for every person, input and setting. A good button is [accessible](/docs/components/button#accessible), [predictable](/docs/components/button#predictable), designed in [every state](/docs/components/button#every-state) and [adaptive](/docs/components/button#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.

::framework-switcher
#react
```tsx
{/* 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>
```

#vue
```vue
<!-- Do: you get the role, focus, keyboard and disabled state for free -->
<button type="button" @click="save">Save</button>

<!-- Don't: there's no role, focus or keyboard support until you rebuild them -->
<div class="button" @click="save">Save</div>
```

#svelte
```svelte
<!-- 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 class="button" onclick={save}>Save</div>
```

#angular
```angular-html
<!-- Do: you get the role, focus, keyboard and disabled state for free -->
<button type="button" (click)="save()">Save</button>

<!-- Don't: there's no role, focus or keyboard support until you rebuild them -->
<div class="button" (click)="save()">Save</div>
```

#solid
```tsx
{/* 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 class="button" onClick={save}>Save</div>
```

#astro
```astro
<!-- Do: you get the role, focus, keyboard and disabled state for free -->
<button id="save" type="button">Save</button>

<!-- Don't: there's no role, focus or keyboard support until you rebuild them -->
<div id="save" class="button">Save</div>

<script>
  document.querySelector("#save")?.addEventListener("click", save);
</script>
```

#vanilla
```html
<!-- Do: you get the role, focus, keyboard and disabled state for free -->
<button id="save" type="button">Save</button>

<!-- Don't: there's no role, focus or keyboard support until you rebuild them -->
<div id="save" class="button">Save</div>

<script type="module">
  document.querySelector("#save").addEventListener("click", save);
</script>
```
::

#### 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](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html)). 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](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)), and the focus ring needs 3:1 against the page ([WCAG 1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)). Disabled buttons are exempt, but they should still be readable.
- Every button is at least 24 × 24px ([WCAG 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)). On touch screens, the hit area grows to 44 × 44px ([WCAG 2.5.5](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html)), 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](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html)). 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](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html), [2.4.12](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-enhanced.html)).

#### 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](https://www.w3.org/WAI/WCAG22/Understanding/pointer-cancellation.html)). Events like `mousedown`, `pointerdown` and `touchstart` fire as soon as you press, and never for the keyboard.

#### Keyboard

| Key                 | Button                       | Link (`href`)       |
| ------------------- | ---------------------------- | ------------------- |
| `Tab`, `Shift+Tab`  | Moves focus to and from it   | The same            |
| `Enter`             | Activates it                 | Follows the link    |
| `Space`             | Activates it, on release     | Scrolls 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](https://www.w3.org/WAI/ARIA/apg/patterns/button/) 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](https://www.w3.org/WAI/WCAG22/Understanding/consistent-identification.html)).

#### Make destructive actions recoverable

::button-destructive-example
::framework-switcher
#react
```tsx
<Button variant="outline">Keep project</Button>
<Button color="error">Delete project</Button>
```

#vue
```vue
<Button variant="outline">Keep project</Button>
<Button color="error">Delete project</Button>
```

#svelte
```svelte
<Button variant="outline">Keep project</Button>
<Button color="error">Delete project</Button>
```

#angular
```angular-html
<button appButton variant="outline">Keep project</button>
<button appButton color="error">Delete project</button>
```

#solid
```tsx
<Button variant="outline">Keep project</Button>
<Button color="error">Delete project</Button>
```

#astro
```astro
<Button variant="outline">Keep project</Button>
<Button color="error">Delete project</Button>
```

#vanilla
```html
<button class="button" type="button" data-variant="outline" data-color="neutral" data-size="md">
  <span data-slot="label">Keep project</span>
</button>
<button class="button" type="button" data-variant="solid" data-color="error" data-size="md">
  <span data-slot="label">Delete project</span>
</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](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html)).
- In a confirmation dialog, name both choices by their outcome, and don't give the destructive one the initial focus ([APG dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)).

#### 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.

::framework-switcher
#react
```tsx
<form onSubmit={signIn}>
  <Button onClick={() => setReveal(!reveal)}>Show password</Button>
  <Button type="submit" color="primary">Sign in</Button>
</form>
```

#vue
```vue
<form @submit.prevent="signIn">
  <Button @click="reveal = !reveal">Show password</Button>
  <Button type="submit" color="primary">Sign in</Button>
</form>
```

#svelte
```svelte
<form onsubmit={signIn}>
  <Button onclick={() => (reveal = !reveal)}>Show password</Button>
  <Button type="submit" color="primary">Sign in</Button>
</form>
```

#angular
```angular-html
<form (ngSubmit)="signIn()">
  <button appButton (click)="reveal.set(!reveal())">Show password</button>
  <button appButton type="submit" color="primary">Sign in</button>
</form>
```

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

#astro
```astro
<form method="post">
  <Button id="reveal">Show password</Button>
  <Button type="submit" color="primary">Sign in</Button>
</form>
```

#vanilla
```html
<form method="post">
  <button class="button" type="button" id="reveal" data-variant="solid" data-color="neutral" data-size="md">
    <span data-slot="label">Show password</span>
  </button>
  <button class="button" type="submit" data-variant="solid" data-color="primary" data-size="md">
    <span data-slot="label">Sign in</span>
  </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`](/docs/components/button#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.

::button-loading-example
::framework-switcher
#react
```tsx
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>
    </>
  );
}
```

#vue
```vue
<script setup lang="ts">
import { ref } from "vue";

const saving = ref(false);
const status = ref("");

async function save() {
  saving.value = true;
  try {
    await api.save(form);
    status.value = "Changes saved";
  } catch {
    status.value = "Couldn't save your changes. Try again.";
  } finally {
    saving.value = false;
  }
}
</script>

<template>
  <Button color="primary" :loading="saving" loading-label="Saving" @click="save">
    Save changes
  </Button>
  <p role="status">{{ status }}</p>
</template>
```

#svelte
```svelte
<script lang="ts">
  let saving = $state(false);
  let status = $state("");

  async function save() {
    saving = true;
    try {
      await api.save(form);
      status = "Changes saved";
    } catch {
      status = "Couldn't save your changes. Try again.";
    } finally {
      saving = false;
    }
  }
</script>

<Button color="primary" loading={saving} loadingLabel="Saving" onclick={save}>
  Save changes
</Button>
<p role="status">{status}</p>
```

#angular
```angular-ts
import { Component, signal } from "@angular/core";

@Component({
  selector: "app-save-changes",
  imports: [Button],
  template: `
    <button appButton color="primary" [loading]="saving()" loadingLabel="Saving" (click)="save()">
      Save changes
    </button>
    <p role="status">{{ status() }}</p>
  `,
})
export class SaveChanges {
  saving = signal(false);
  status = signal("");

  async save() {
    this.saving.set(true);
    try {
      await api.save(form);
      this.status.set("Changes saved");
    } catch {
      this.status.set("Couldn't save your changes. Try again.");
    } finally {
      this.saving.set(false);
    }
  }
}
```

#solid
```tsx
import { createSignal } from "solid-js";

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

  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>
    </>
  );
}
```

#astro
```astro
<Button id="save" color="primary">Save changes</Button>
<p id="status" role="status"></p>

<script>
  import { announce, prepareAnnouncer } from "../components/announce";

  const button = document.querySelector("#save")!;
  const status = document.querySelector("#status")!;
  // The Spinner's markup, from its DOM contract.
  const spinner = `<svg class="spinner" viewBox="0 0 16 16" aria-hidden="true" data-slot="spinner">…</svg>`;

  // Screen readers only announce changes to a live region that's already in the page.
  prepareAnnouncer();

  // Loading uses aria-disabled rather than disabled, so the button keeps its focus.
  function setLoading(loading: boolean) {
    if (loading) {
      button.setAttribute("aria-disabled", "true");
      button.setAttribute("data-loading", "");
      button.insertAdjacentHTML("beforeend", spinner);
      announce("Saving");
    } else {
      button.removeAttribute("aria-disabled");
      button.removeAttribute("data-loading");
      button.querySelector('[data-slot="spinner"]')?.remove();
    }
  }

  button.addEventListener("click", async () => {
    if (button.hasAttribute("data-loading")) return;
    setLoading(true);
    try {
      await api.save(form);
      status.textContent = "Changes saved";
    } catch {
      status.textContent = "Couldn't save your changes. Try again.";
    } finally {
      setLoading(false);
    }
  });
</script>
```

#vanilla
```html
<button class="button" type="button" id="save" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Save changes</span>
</button>
<p id="status" role="status"></p>

<script type="module">
  import { announce, prepareAnnouncer } from "./announce.js";

  const button = document.querySelector("#save");
  const status = document.querySelector("#status");
  // The Spinner's markup, from its DOM contract.
  const spinner = `<svg class="spinner" viewBox="0 0 16 16" aria-hidden="true" data-slot="spinner">…</svg>`;

  // Screen readers only announce changes to a live region that's already in the page.
  prepareAnnouncer();

  // Loading uses aria-disabled rather than disabled, so the button keeps its focus.
  function setLoading(loading) {
    if (loading) {
      button.setAttribute("aria-disabled", "true");
      button.setAttribute("data-loading", "");
      button.insertAdjacentHTML("beforeend", spinner);
      announce("Saving");
    } else {
      button.removeAttribute("aria-disabled");
      button.removeAttribute("data-loading");
      button.querySelector('[data-slot="spinner"]').remove();
    }
  }

  button.addEventListener("click", async () => {
    if (button.hasAttribute("data-loading")) return;
    setLoading(true);
    try {
      await api.save(form);
      status.textContent = "Changes saved";
    } catch {
      status.textContent = "Couldn't save your changes. Try again.";
    } finally {
      setLoading(false);
    }
  });
</script>
```
::
::

- 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](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html)).

#### 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".

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

#vue
```vue
<Button variant="ghost" :aria-pressed="muted" @click="muted = !muted">
  <template #leading><VolumeXIcon v-if="muted" /><Volume2Icon v-else /></template>
  Mute
</Button>
```

#svelte
```svelte
<Button variant="ghost" aria-pressed={muted} onclick={() => (muted = !muted)}>
  {#snippet leading()}
    {#if muted}<VolumeXIcon />{:else}<Volume2Icon />{/if}
  {/snippet}
  Mute
</Button>
```

#angular
```angular-html
<button appButton variant="ghost" [attr.aria-pressed]="muted()" (click)="muted.set(!muted())">
  <lucide-icon leading [img]="muted() ? VolumeXIcon : Volume2Icon" />
  Mute
</button>
```

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

#astro
```astro
<Button id="mute" variant="ghost" aria-pressed="false">
  <VolumeXIcon slot="leading" />
  Mute
</Button>

<script>
  const mute = document.querySelector("#mute")!;
  mute.addEventListener("click", () => {
    const pressed = mute.getAttribute("aria-pressed") === "true";
    mute.setAttribute("aria-pressed", String(!pressed));
  });
</script>
```

#vanilla
```html
<button class="button" type="button" id="mute" aria-pressed="false" data-variant="ghost" data-color="neutral" data-size="md">
  <span data-slot="leading" aria-hidden="true"><svg class="lucide-volume-x">…</svg></span>
  <span data-slot="label">Mute</span>
</button>

<script type="module">
  const mute = document.querySelector("#mute");
  mute.addEventListener("click", () => {
    const pressed = mute.getAttribute("aria-pressed") === "true";
    mute.setAttribute("aria-pressed", String(!pressed));
  });
</script>
```
::

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](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) is a better fit.

#### Menu buttons

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](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) pattern.

::framework-switcher
#react
```tsx
<Button
  variant="outline"
  aria-haspopup="menu"
  aria-expanded={open}
  onClick={() => setOpen(!open)}
  trailing={<ChevronDownIcon />}
>
  Options
</Button>
```

#vue
```vue
<Button variant="outline" aria-haspopup="menu" :aria-expanded="open" @click="open = !open">
  Options
  <template #trailing><ChevronDownIcon /></template>
</Button>
```

#svelte
```svelte
<Button variant="outline" aria-haspopup="menu" aria-expanded={open} onclick={() => (open = !open)}>
  Options
  {#snippet trailing()}<ChevronDownIcon />{/snippet}
</Button>
```

#angular
```angular-html
<button
  appButton
  variant="outline"
  aria-haspopup="menu"
  [attr.aria-expanded]="open()"
  (click)="open.set(!open())"
>
  Options
  <lucide-icon trailing [img]="ChevronDownIcon" />
</button>
```

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

#astro
```astro
<Button id="options" variant="outline" aria-haspopup="menu" aria-expanded="false">
  Options
  <ChevronDownIcon slot="trailing" />
</Button>

<script>
  const options = document.querySelector("#options")!;
  options.addEventListener("click", () => {
    const open = options.getAttribute("aria-expanded") === "true";
    options.setAttribute("aria-expanded", String(!open));
  });
</script>
```

#vanilla
```html
<button class="button" type="button" id="options" aria-haspopup="menu" aria-expanded="false" data-variant="outline" data-color="neutral" data-size="md">
  <span data-slot="label">Options</span>
  <span data-slot="trailing" aria-hidden="true"><svg class="lucide-chevron-down">…</svg></span>
</button>

<script type="module">
  const options = document.querySelector("#options");
  options.addEventListener("click", () => {
    const open = options.getAttribute("aria-expanded") === "true";
    options.setAttribute("aria-expanded", String(!open));
  });
</script>
```
::

### 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](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html)). We recommend letting the spinner pulse instead of turning, so it still shows that the button is busy, which the [Spinner](/docs/components/spinner#motion) 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](https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html)), with wider line, letter and word spacing ([WCAG 1.4.12](https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html)) and on screens as narrow as 320px ([WCAG 1.4.10](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html)). 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](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html)).

::button-languages-example
::framework-switcher
#react
```tsx
<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>
```

#vue
```vue
<div class="w-40">
  <Button color="primary" lang="de">Alle Änderungen speichern</Button>
</div>

<div dir="rtl" lang="ar">
  <Button variant="outline">
    متابعة
    <template #trailing><ArrowRightIcon class="flip-in-rtl" /></template>
  </Button>
</div>
```

#svelte
```svelte
<div class="w-40">
  <Button color="primary" lang="de">Alle Änderungen speichern</Button>
</div>

<div dir="rtl" lang="ar">
  <Button variant="outline">
    متابعة
    {#snippet trailing()}<ArrowRightIcon class="flip-in-rtl" />{/snippet}
  </Button>
</div>
```

#angular
```angular-html
<div class="w-40">
  <button appButton color="primary" lang="de">Alle Änderungen speichern</button>
</div>

<div dir="rtl" lang="ar">
  <button appButton variant="outline">
    متابعة
    <lucide-icon trailing [img]="ArrowRightIcon" class="flip-in-rtl" />
  </button>
</div>
```

#solid
```tsx
<div class="w-40">
  <Button color="primary" lang="de">Alle Änderungen speichern</Button>
</div>

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

#astro
```astro
<div class="w-40">
  <Button color="primary" lang="de">Alle Änderungen speichern</Button>
</div>

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

#vanilla
```html
<div class="w-40">
  <button class="button" type="button" lang="de" data-variant="solid" data-color="primary" data-size="md">
    <span data-slot="label">Alle Änderungen speichern</span>
  </button>
</div>

<div dir="rtl" lang="ar">
  <button class="button" type="button" data-variant="outline" data-color="neutral" data-size="md">
    <span data-slot="label">متابعة</span>
    <span data-slot="trailing" aria-hidden="true"><svg class="lucide-arrow-right flip-in-rtl">…</svg></span>
  </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:

```css
.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.

::framework-switcher
#react
```tsx
import { Button } from "@/components/Button";

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

#vue
```vue
<script setup lang="ts">
import Button from "@/components/Button.vue";
</script>

<template>
  <Button color="primary" @click="save">Save changes</Button>
</template>
```

#svelte
```svelte
<script lang="ts">
  import Button from "$lib/components/Button.svelte";
</script>

<Button color="primary" onclick={save}>Save changes</Button>
```

#angular
```angular-ts
import { Component } from "@angular/core";
import { Button } from "./button";

@Component({
  selector: "app-settings",
  imports: [Button],
  template: `<button appButton color="primary" (click)="save()">Save changes</button>`,
})
export class Settings {
  save() {
    // …
  }
}
```

#solid
```tsx
import { Button } from "~/components/Button";

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

#astro
```astro
---
import Button from "../components/Button.astro";
---

<Button id="save" color="primary">Save changes</Button>

<script>
  document.querySelector("#save")?.addEventListener("click", save);
</script>
```

#vanilla
```html
<link rel="stylesheet" href="/styles/button.css" />
<link rel="stylesheet" href="/styles/spinner.css" />

<button class="button" type="button" id="save" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Save changes</span>
</button>

<script type="module">
  document.querySelector("#save").addEventListener("click", save);
</script>
```
::

### 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:

| Name                   | Kind  | What it means                                  |
| ---------------------- | ----- | ---------------------------------------------- |
| `variant`              | Prop  | The emphasis, from `solid` down to `link`      |
| `color`                | Prop  | The intent, from `primary` to `error`          |
| `size`                 | Prop  | `xs`, `sm`, `md`, `lg` or `xl`                 |
| `label`                | Prop  | The text content, when it's plain text         |
| `disabled`             | Prop  | The component can't be used                    |
| `loading`              | Prop  | The component is busy with an action           |
| `leading`, `trailing`  | Slots | Content before and after the main content      |
| `click`                | Event | The 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"`.

| Prop                    | Type                                                                                  | Default    | Description                                                        |
| ----------------------- | ------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------ |
| `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                                              |
| `label`                 | `string`                                                                              |            | The label, when it's plain text. The default slot takes precedence over it |
| `iconOnly`              | `boolean`                                                                             | `false`    | Shows only the leading icon, while the label still names the button |
| `type`                  | `"button" \| "submit" \| "reset"`                                                     | `"button"` | The native button type                                             |
| `href`                  | `string`                                                                              |            | Renders a link that looks like a button                            |
| `disabled`              | `boolean`                                                                             | `false`    | Blocks activation                                                  |
| `focusableWhenDisabled` | `boolean`                                                                             | `false`    | Keeps a disabled button in the tab order                           |
| `loading`               | `boolean`                                                                             | `false`    | Blocks activation and shows a spinner, while keeping focus and size |
| `loadingLabel`          | `string`                                                                              | `"Loading"` | Announced to screen readers when loading starts                   |

| Event   | Payload      | Description                                                                 |
| ------- | ------------ | --------------------------------------------------------------------------- |
| `click` | `MouseEvent` | Fires when the button is activated, but never while it's disabled or loading |

| Slot       | Description                                                     |
| ---------- | --------------------------------------------------------------- |
| `default`  | The label                                                       |
| `leading`  | An icon before the label, which is also the icon of an icon-only button |
| `trailing` | An icon after the label                                         |

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

::framework-switcher
#react
```ts
import type { ButtonProps } from "@/components/Button";

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

#vue
```ts
import type { ButtonProps } from "@/components/Button.vue";

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

#svelte
```ts
import type { ButtonProps } from "$lib/components/Button.svelte";

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

#angular
```ts
import type { ButtonProps } from "./button";

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

#solid
```ts
import type { ButtonProps } from "~/components/Button";

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

#astro
```ts
import type { ComponentProps } from "astro/types";
import Button from "../components/Button.astro";

type ButtonProps = ComponentProps<typeof 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.

::framework-switcher
#react
```tsx
<Button type="submit" name="intent" value="publish" color="primary">Publish</Button>
<Button popoverTarget="filters" variant="outline">Filters</Button>
```

#vue
```vue
<Button type="submit" name="intent" value="publish" color="primary">Publish</Button>
<Button popovertarget="filters" variant="outline">Filters</Button>
```

#svelte
```svelte
<Button type="submit" name="intent" value="publish" color="primary">Publish</Button>
<Button popovertarget="filters" variant="outline">Filters</Button>
```

#angular
```angular-html
<button appButton type="submit" name="intent" value="publish" color="primary">Publish</button>
<button appButton popovertarget="filters" variant="outline">Filters</button>
```

#solid
```tsx
<Button type="submit" name="intent" value="publish" color="primary">Publish</Button>
<Button popovertarget="filters" variant="outline">Filters</Button>
```

#astro
```astro
<Button type="submit" name="intent" value="publish" color="primary">Publish</Button>
<Button popovertarget="filters" variant="outline">Filters</Button>
```

#vanilla
```html
<button class="button" type="submit" name="intent" value="publish" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Publish</span>
</button>
<button class="button" type="button" popovertarget="filters" data-variant="outline" data-color="neutral" data-size="md">
  <span data-slot="label">Filters</span>
</button>
```
::

#### Links and routers

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`:

::framework-switcher
#react
```tsx
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>
  );
}
```

#vue
```vue
<!-- NuxtLink has the same custom slot. -->
<RouterLink v-slot="{ href, navigate }" to="/settings" custom>
  <Button :href="href" @click="navigate">Settings</Button>
</RouterLink>
```

#svelte
```svelte
<!-- SvelteKit's router handles every link on the page. -->
<Button href="/settings">Settings</Button>
```

#angular
```angular-html
<!-- The Button is the link itself, so routerLink sets its href and handles its clicks. -->
<a appButton routerLink="/settings">Settings</a>
```

#solid
```tsx
{/* Solid Router handles every link on the page. */}
<Button href="/settings">Settings</Button>
```

#astro
```astro
<!-- Astro's pages are plain links, which its view transitions handle when they're on. -->
<Button href="/settings">Settings</Button>
```

#vanilla
```html
<a class="button" href="/settings" data-variant="solid" data-color="neutral" data-size="md">
  <span data-slot="label">Settings</span>
</a>
```
::

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.
- Menus and popovers set `aria-haspopup`, `aria-expanded` and `aria-controls` on the Button, which passes them straight through.
- The loading spinner is a [Spinner](/docs/components/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:

| State    | How you control it                                 |
| -------- | -------------------------------------------------- |
| Disabled | `disabled`, and `focusableWhenDisabled`             |
| Loading  | `loading`, set when the action starts (see [Loading](/docs/components/button#loading))  |
| Pressed  | `aria-pressed`, bound to whether it's on            |
| Expanded | `aria-expanded`, bound to whether its popup is open |
| Focus    | A 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:

```yaml
- 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:

```ts
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:

```yaml
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`](/raw/docs/components/button.yaml){external=""}, 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:

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

And here's what it renders:

```html
<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](/docs/components/spinner#deterministic).
- 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](/docs/components/button#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](https://github.com/dequelabs/axe-core) to check names, contrast, target size and nesting, and use an [ARIA snapshot](https://playwright.dev/docs/aria-snapshots) to check what agents will see:

```ts
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:

::framework-switcher
#react
```
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.
```

#vue
```
Build a Button component for our Vue 3 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.
```

#svelte
```
Build a Button component for our Svelte 5 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.
```

#angular
```
Build a Button component for our Angular 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.
```

#solid
```
Build a Button component for our Solid 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.
```

#astro
```
Build a Button component for our Astro 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.
```

#vanilla
```
Build a Button for our design system in plain HTML, CSS and JavaScript that meets the
Open Components Button standard: https://opencomponents.dev/raw/docs/components/button.md

Follow 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

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

### UX rules

| Rule                        | Level  | Scope     | Requirement | Basis | Check |
| --------------------------- | ------ | --------- | ----------- | ----- | --------------------------------- |
| `button/native-element`     | Must   | Component | It'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-navigate`     | Must   | Usage     | Navigation always uses a link, so a button never changes the URL | Open Components | Review |
| `button/accessible-name`    | Must   | Both      | Every button has a name, and an icon-only button has a visually hidden label | WCAG 4.1.2 (A), Open Components | axe `button-name`, unit test |
| `button/label-in-name`      | Must   | Usage     | An `aria-label` or `aria-labelledby` includes the visible label, ideally at the start | WCAG 2.5.3 (A) | axe `label-content-name-mismatch` |
| `button/decorative-icons`   | Must   | Component | Icons are hidden from assistive technologies with `aria-hidden`, so they don't add to the button's name | WCAG 1.1.1 (A) | Unit test |
| `button/verb-labels`        | Should | Usage     | Labels start with a verb and name the outcome, and end with `…` when more input follows | Open Components | Review |
| `button/keyboard`           | Must   | Component | `Enter` and `Space` activate a button, while `Enter` follows a link | WCAG 2.1.1 (A), APG | Unit test |
| `button/activate-on-click`  | Must   | Usage     | Actions run on `click`, never on `mousedown`, `pointerdown` or `touchstart` | Open Components, beyond WCAG 2.5.2 (A) | Review |
| `button/no-nesting`         | Must   | Usage     | There's nothing interactive inside a button, and no button inside a link | HTML | axe `nested-interactive` |
| `button/dom-order`          | Must   | Usage     | Buttons come in the same order in the DOM as on screen, without `order` or `row-reverse` | Open Components, beyond WCAG 1.3.2, 2.4.3 (A) | Keyboard |
| `button/disabled-inert`     | Must   | Component | A disabled or loading button does nothing, so there's no event, no submission and no navigation | HTML, WAI-ARIA | Unit test |
| `button/explain-disabled`   | Should | Usage     | An enabled button with validation is preferred over a disabled one, and a disabled one explains why | Open Components | Review |
| `button/keep-focus`         | Must   | Both      | Disabling 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 focusable | Open Components | Unit test, keyboard |
| `button/loading`            | Must   | Component | Loading keeps the button's size and name, blocks repeated activation and is announced | WCAG 4.1.3 (AA), Open Components | Unit test |
| `button/announce-outcome`   | Should | Usage     | The page announces the action's outcome with a status message | Open Components, beyond WCAG 4.1.3 (AA) | Screen reader |
| `button/toggle-pressed`     | Must   | Both      | A toggle button sets `aria-pressed` and keeps its label | WCAG 4.1.2 (A), APG | Unit test |
| `button/popup-expanded`     | Must   | Both      | A button that opens a popup sets `aria-haspopup` and `aria-expanded` | WCAG 4.1.2 (A), APG | Unit test |
| `button/focus-after`        | Should | Usage     | After activation, focus moves as the APG describes, and never falls back to the top of the page | APG | Keyboard |
| `button/focus-not-obscured` | Must   | Usage     | Sticky headers and footers never cover a focused button, for example thanks to `scroll-padding` | WCAG 2.4.11 (AA), 2.4.12 (AAA) | Keyboard |
| `button/destructive`        | Should | Usage     | A destructive action uses `error`, names what it destroys, and can either be undone or asks for confirmation | Open Components, beyond WCAG 3.3.4 (AA) | Review |
| `button/mirrors-in-rtl`     | Should | Both      | It uses logical properties, and icons that point along the reading direction flip in right-to-left layouts | Open Components | Review |

### DX rules

| Rule                       | Level  | Scope     | Requirement | Basis | Check |
| -------------------------- | ------ | --------- | ----------- | ----- | --------- |
| `button/shared-vocabulary` | Must   | Component | It uses the shared names: `variant`, `color`, `size`, `label`, `disabled`, `loading`, `leading`, `trailing` and `click` | Open Components | Review |
| `button/typed`             | Must   | Component | Props, events and slots are typed, with unions rather than `string` | Open Components | Type check |
| `button/explicit-type`     | Must   | Both      | `type` defaults to `"button"`, and submit buttons say `type="submit"` explicitly | Open Components | Unit test |
| `button/root-element`      | Must   | Component | The root element is the button itself, so attributes, listeners and refs land on it | Open Components | Unit test |
| `button/stateless`         | Should | Component | Every state is a prop or an attribute that the parent controls | Open Components | Review |

### AX rules

| Rule                       | Level  | Scope     | Requirement | Basis | Check |
| -------------------------- | ------ | --------- | ----------- | ----- | --------- |
| `button/role-and-name`     | Must   | Component | It can be found by its role and name alone, with `getByRole("button", { name })` | WCAG 4.1.2 (A) | Unit test |
| `button/deterministic-dom` | Must   | Component | The same props render the same markup, and states change attributes, never elements | Open Components | Unit test |
| `button/data-attributes`   | Should | Component | Props are mirrored as `data-*` attributes, and parts are marked with `data-slot` | Open Components | Unit test |
| `button/dev-warnings`      | Should | Component | It warns during development when a button has no accessible name | Open Components | Unit 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](/docs/components/spinner#reference-implementation) 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](https://vitest.dev), set to `environment: "jsdom"` and `globals: true`, along with `@testing-library/vue`, `@testing-library/user-event` and `@testing-library/jest-dom`.

::code-collapse
:::code-group
```vue [Button.vue]
<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>
```
```ts [announce.ts]
/**
 * Announces a message to screen reader users through one polite live region,
 * shared by every component on the page.
 *
 * Screen readers only announce changes to a live region that's already in the
 * page, so components prepare it when they mount, long before anything is
 * announced. Each message is a new node, so repeating a message announces it again.
 */
let region: HTMLElement | undefined;

export function prepareAnnouncer(): HTMLElement {
  if (region?.isConnected) return region;
  region = document.createElement("div");
  region.setAttribute("role", "status");
  // Visually hidden, but not with display: none, which would take it out of the accessibility tree.
  region.style.cssText =
    "position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
  document.body.append(region);
  return region;
}

export function announce(message: string) {
  const node = document.createElement("div");
  node.textContent = message;
  prepareAnnouncer().append(node);
  setTimeout(() => node.remove(), 5000);
}
```
```css [tokens.css]
/*
 * The theme tokens the button reads, which are three per colour plus the focus
 * ring. The values come from the Tailwind CSS palette, and every pairing passes
 * WCAG AA (4.5:1) in every variant, at rest, on hover and when pressed, on both
 * white and zinc-900.
 *
 * light-dark() follows `color-scheme`, so set `color-scheme: light dark` to follow
 * the system, or `light` and `dark` on your theme classes.
 */
:root {
  /* The solid fill, the label on top of it, and the label on the page. */
  --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));

  --color--secondary: light-dark(oklch(52.5% 0.223 3.958), oklch(71.8% 0.202 349.761));
  --color--secondary-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823));
  --color--secondary-text: light-dark(oklch(45.9% 0.187 3.815), oklch(82.3% 0.12 346.018));

  --color--neutral: light-dark(oklch(21% 0.006 285.885), oklch(96.7% 0.001 286.375));
  --color--neutral-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823));
  --color--neutral-text: light-dark(oklch(27.4% 0.006 286.033), oklch(92% 0.004 286.32));

  --color--success: light-dark(oklch(44.8% 0.119 151.328), oklch(79.2% 0.209 151.711));
  --color--success-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823));
  --color--success-text: light-dark(oklch(44.8% 0.119 151.328), oklch(87.1% 0.15 154.449));

  --color--info: light-dark(oklch(50% 0.134 242.749), oklch(74.6% 0.16 232.661));
  --color--info-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823));
  --color--info-text: light-dark(oklch(44.3% 0.11 240.79), oklch(82.8% 0.111 230.318));

  --color--warning: oklch(82.8% 0.189 84.429);
  --color--warning-contrast: light-dark(oklch(27.9% 0.077 45.635), oklch(14.1% 0.005 285.823));
  --color--warning-text: light-dark(oklch(47.3% 0.137 46.201), oklch(87.9% 0.169 91.605));

  --color--error: light-dark(oklch(50.5% 0.213 27.518), oklch(70.4% 0.191 22.216));
  --color--error-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823));
  --color--error-text: light-dark(oklch(44.4% 0.177 26.899), oklch(80.8% 0.114 19.571));

  /* One focus colour for every component, with 3:1 or more against the page. */
  --color--focus: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935));
}
```
```ts [Button.test.ts]
import { render, screen } from "@testing-library/vue";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { h, ref, type ComponentPublicInstance } from "vue";
import Button from "./Button.vue";

const icon = () => h("svg");

describe("Button", () => {
  it("is a button, named by its label, that never submits by accident", () => {
    render(Button, { slots: { default: "Save changes" } });

    const button = screen.getByRole("button", { name: "Save changes" });
    expect(button).toHaveAttribute("type", "button");
  });

  it("activates with a click, Enter and Space", async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();
    render(Button, { props: { onClick }, slots: { default: "Save" } });

    await user.click(screen.getByRole("button", { name: "Save" }));
    await user.keyboard("{Enter}");
    await user.keyboard(" ");
    expect(onClick).toHaveBeenCalledTimes(3);
  });

  it("activates a link with Enter, but not with Space, like any other link", async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();
    render(Button, { props: { href: "#settings", onClick }, slots: { default: "Settings" } });

    await user.tab();
    await user.keyboard("{Enter}");
    await user.keyboard(" ");
    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it("does nothing while disabled, and leaves the tab order", async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();
    render(Button, { props: { onClick, disabled: true }, slots: { default: "Save" } });

    const button = screen.getByRole("button", { name: "Save" });
    await user.click(button);
    await user.tab();
    expect(button).toBeDisabled();
    expect(button).not.toHaveFocus();
    expect(onClick).not.toHaveBeenCalled();
  });

  it("stays in the tab order when disabled, if asked to", async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();
    render(Button, {
      props: { onClick, disabled: true, focusableWhenDisabled: true },
      slots: { default: "Save" },
    });

    const button = screen.getByRole("button", { name: "Save" });
    await user.tab();
    await user.keyboard("{Enter}");
    expect(button).toHaveFocus();
    expect(button).toHaveAttribute("aria-disabled", "true");
    expect(onClick).not.toHaveBeenCalled();
  });

  it("keeps its focus and name while loading, and can't be activated again", async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();
    const { rerender } = render(Button, { props: { onClick }, slots: { default: "Save" } });

    const button = screen.getByRole("button", { name: "Save" });
    await user.click(button);
    await rerender({ onClick, loading: true });
    await user.keyboard("{Enter}");
    await user.click(button);

    expect(button).toHaveFocus();
    expect(button).toHaveAccessibleName("Save");
    // Browsers move focus off a natively disabled button (jsdom doesn't).
    expect(button).not.toBeDisabled();
    expect(button).toHaveAttribute("aria-disabled", "true");
    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it("announces that it's loading", async () => {
    const { rerender } = render(Button, {
      props: { loadingLabel: "Saving" },
      slots: { default: "Save" },
    });

    await rerender({ loadingLabel: "Saving", loading: true });
    expect(await screen.findByRole("status")).toHaveTextContent("Saving");
  });

  it("doesn't submit its form while loading, even on Enter in a field", async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn((event: Event) => event.preventDefault());
    render({
      render: () =>
        h("form", { onSubmit }, [
          h("input", { "aria-label": "Name" }),
          h(Button, { type: "submit", loading: true }, () => "Save"),
        ]),
    });

    await user.type(screen.getByRole("textbox", { name: "Name" }), "Ada{Enter}");
    await user.click(screen.getByRole("button", { name: "Save" }));
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it("renders a link for navigation, with no href while disabled", async () => {
    const { rerender } = render(Button, {
      props: { href: "/settings" },
      slots: { default: "Settings" },
    });

    const link = screen.getByRole("link", { name: "Settings" });
    expect(link).toHaveAttribute("href", "/settings");
    expect(link).not.toHaveAttribute("type");

    await rerender({ href: "/settings", disabled: true });
    expect(link).not.toHaveAttribute("href");
    expect(link).toHaveAttribute("aria-disabled", "true");
  });

  it("renders the button itself, so attributes, listeners and refs land on it", async () => {
    const user = userEvent.setup();
    const onFocus = vi.fn();
    const instance = ref<ComponentPublicInstance>();
    render({
      render: () => h(Button, { ref: instance, class: "pill", name: "intent", onFocus }, () => "Save"),
    });

    const button = screen.getByRole("button", { name: "Save" });
    await user.tab();
    expect(button).toHaveClass("button", "pill");
    expect(button).toHaveAttribute("name", "intent");
    expect(onFocus).toHaveBeenCalled();
    expect(instance.value?.$el).toBe(button);
  });

  it("names an icon-only button by its hidden label", () => {
    render(Button, { props: { label: "Settings", iconOnly: true }, slots: { leading: icon } });

    expect(screen.getByRole("button", { name: "Settings" })).toBeVisible();
  });

  it("hides its icons from assistive technologies, so they don't add to its name", () => {
    const titled = () => h("svg", [h("title", "Plus")]);
    render(Button, { slots: { default: "New project", leading: titled, trailing: titled } });

    expect(screen.getByRole("button", { name: "New project" })).toBeInTheDocument();
  });

  it("passes ARIA states through, for toggle and menu buttons", () => {
    render(Button, { attrs: { "aria-pressed": "true" }, slots: { default: "Mute" } });
    render(Button, {
      attrs: { "aria-haspopup": "menu", "aria-expanded": "true" },
      slots: { default: "Options" },
    });

    expect(screen.getByRole("button", { name: "Mute", pressed: true })).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Options", expanded: true })).toHaveAttribute(
      "aria-haspopup",
      "menu",
    );
  });

  it("mirrors its props as data attributes", () => {
    render(Button, {
      props: { variant: "soft", color: "primary", size: "lg" },
      slots: { default: "Save" },
    });

    const button = screen.getByRole("button", { name: "Save" });
    expect(button).toHaveAttribute("data-variant", "soft");
    expect(button).toHaveAttribute("data-color", "primary");
    expect(button).toHaveAttribute("data-size", "lg");
  });

  it("renders its parts in a fixed order, and keeps its element across states", async () => {
    const { rerender } = render(Button, {
      slots: { default: "Save", leading: icon, trailing: icon },
    });

    const button = screen.getByRole("button", { name: "Save" });
    const parts = () => [...button.children].map((part) => part.getAttribute("data-slot"));
    expect(parts()).toEqual(["leading", "label", "trailing"]);

    await rerender({ loading: true });
    expect(screen.getByRole("button", { name: "Save" })).toBe(button);
    expect(parts()).toEqual(["leading", "label", "trailing", "spinner"]);
  });

  it("warns in development when it has no accessible name", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    render(Button, { slots: { leading: icon } });

    expect(warn).toHaveBeenCalledWith(expect.stringContaining("no accessible name"), expect.anything());
    warn.mockRestore();
  });
});
```
:::
::

## FAQ


#### Button or link?

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…                                   | Use                                  | Renders                                           |
| ----------------------------------------------------------- | ------------------------------------ | ------------------------------------------------- |
| Runs an action on the page, like saving, sending or deleting | Button                              | `<button type="button">`                          |
| Submits a form                                              | Button, with `type="submit"`         | `<button type="submit">`                          |
| Takes you to another page, a section or a file              | Button, with `href`                  | `<a href>`                                        |
| Switches something on or off                                | [Toggle button](/docs/components/button#toggle-buttons) | `<button aria-pressed>`             |
| Opens a menu                                                | [Menu button](/docs/components/button#menu-buttons)     | `<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-link-example
::framework-switcher
#react
```tsx
<Button href="/docs" color="primary" trailing={<ArrowRightIcon />}>
  Read the introduction
</Button>
```

#vue
```vue
<Button href="/docs" color="primary">
  Read the introduction
  <template #trailing><ArrowRightIcon /></template>
</Button>
```

#svelte
```svelte
<Button href="/docs" color="primary">
  Read the introduction
  {#snippet trailing()}<ArrowRightIcon />{/snippet}
</Button>
```

#angular
```angular-html
<a appButton href="/docs" color="primary">
  Read the introduction
  <lucide-icon trailing [img]="ArrowRightIcon" />
</a>
```

#solid
```tsx
<Button href="/docs" color="primary" trailing={<ArrowRightIcon />}>
  Read the introduction
</Button>
```

#astro
```astro
<Button href="/docs" color="primary">
  Read the introduction
  <ArrowRightIcon slot="trailing" />
</Button>
```

#vanilla
```html
<a class="button" href="/docs" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">Read the introduction</span>
  <span data-slot="trailing" aria-hidden="true"><svg class="lucide-arrow-right">…</svg></span>
</a>
```
::
::

::framework-switcher
#react
```tsx
{/* 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>
```

#vue
```vue
<!-- 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" @click="router.push('/pricing')">See pricing</Button>
```

#svelte
```svelte
<!-- 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={() => goto("/pricing")}>See pricing</Button>
```

#angular
```angular-html
<!-- Do: use a link for navigation, even when it looks like a button -->
<a appButton routerLink="/pricing" color="primary">See pricing</a>

<!-- Don't: this can't be opened in a new tab, and screen readers won't list it as a link -->
<button appButton color="primary" (click)="router.navigate(['/pricing'])">See pricing</button>
```

#solid
```tsx
{/* 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>
```

#astro
```astro
<!-- 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 id="pricing" color="primary">See pricing</Button>

<script>
  import { navigate } from "astro:transitions/client";

  document.querySelector("#pricing")?.addEventListener("click", () => navigate("/pricing"));
</script>
```

#vanilla
```html
<!-- Do: use a link for navigation, even when it looks like a button -->
<a class="button" href="/pricing" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">See pricing</span>
</a>

<!-- Don't: this can't be opened in a new tab, and screen readers won't list it as a link -->
<button class="button" type="button" id="pricing" data-variant="solid" data-color="primary" data-size="md">
  <span data-slot="label">See pricing</span>
</button>

<script type="module">
  document.querySelector("#pricing").addEventListener("click", () => location.assign("/pricing"));
</script>
```
::


::accordion
:::accordion-item{label="Should a button have a pointer cursor?"}
Our reference implementation uses `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.
:::

:::accordion-item{label="Why not disable the submit button until the form is valid?"}
Because a disabled button can't explain itself. People don't know which field is wrong, keyboard users can't focus it to find out, and screen reader users might not find it at all. It's better to keep it enabled, validate on submit and move focus to the first error, with a message next to each field.
:::

:::accordion-item{label='Can I use <input type="submit">?'}
You can, but its label lives in an attribute, so it can't hold icons or a spinner. A `<button type="submit">` does everything it does, and more.
:::

:::accordion-item{label="Does the Button work before JavaScript loads?"}
Yes, as far as HTML can take it. The server renders a real `<button>` or `<a href>`, so even before the page hydrates, links navigate, submit buttons submit and `disabled` buttons stay disabled. Only the parts that need JavaScript have to wait for it, which are your `click` handlers and the blocking of focusable disabled and loading buttons.
:::

:::accordion-item{label="Why a visually hidden label rather than aria-label?"}
Both give the button a name, but hidden text is ordinary text, so it gets translated along with the rest of the page. It's also a single mechanism for every button, whether it's icon-only or not. We'd only reach for `aria-label` when there's no other choice, and then make sure it includes the visible text.
:::
::

## Sources

- [WAI-ARIA Authoring Practices: Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/), [Menu Button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) and [Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
- [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/)
- [HTML Standard: the button element](https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element)
- [CSS Color Adjustment: forced colors mode](https://www.w3.org/TR/css-color-adjust-1/)
- [Material Design 3: state layers](https://m3.material.io/foundations/interaction/states/state-layers)
- [React Aria: Button](https://react-spectrum.adobe.com/react-aria/Button.html), for the pending state
- [Primer: Button](https://primer.style/product/components/button/), for the loading state
- [Base UI: Button](https://base-ui.com/react/components/button), for focusable disabled buttons
- [Styleframe: Button](https://www.styleframe.dev/docs/theme/components/button), for the colour and variant vocabulary
