Components

Button

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

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

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

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

Every example on this page runs on our reference implementation, a Vue 3 component that meets every rule in the checklist, so feel free to try things out as you read.

<Button color="primary">
  <template #leading><CheckIcon /></template>
  Save changes
</Button>

At a glance

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

UI

Let's start with how a button looks: its parts, how much attention it asks for, what it means, how big it is and how it shows each of its states.

Anatomy

  1. Container — The <button>, or the <a href> when it navigates. It carries the variant, colour and size.
  2. Leading icon — Optional, and hidden from assistive technologies, since the label already names the action.
  3. Label — The accessible name. When the button is icon-only, it's visually hidden but never removed.
  4. Trailing icon — Optional. It hints at what happens next, like a menu opening or the flow moving on.
  5. Focus ring — A 2px outline for keyboard focus, drawn 2px outside the button so nothing moves.

Every button has a container and a label. Icons are optional, the focus ring appears when you reach the button with the keyboard, and a spinner covers the content while the button is loading. The parts always render in the same order, which you can see in the DOM contract.

Variants

The variant prop sets the button's emphasis, meaning how much attention it asks for. We recommend using it to rank the actions in a view, rather than to decorate them.

<Button color="primary">Solid</Button>
<Button color="primary" variant="outline">Outline</Button>
<Button color="primary" variant="soft">Soft</Button>
<Button color="primary" variant="subtle">Subtle</Button>
<Button color="primary" variant="ghost">Ghost</Button>
<Button color="primary" variant="link">Link</Button>
VariantLooksUse it for
solidFilledThe main action of a view, so there's only one per view or group
outlineA border and no fillSecondary actions next to a solid one
softA tinted fillSecondary actions that need a bit more presence, and dense layouts
subtleA tinted fill with a borderThe same as soft, with an edge that holds up on busy backgrounds
ghostNothing until you hover itTertiary actions in toolbars, table rows and icon-only buttons
linkText that's underlined on hoverLow-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.

Colours

The color prop describes the intent behind an action rather than its hue. A name like primary will still make sense after a rebrand, while blue might not. The label is what carries the meaning, so people who can't tell the colours apart, or who browse with forced colours, still get the same message (WCAG 1.4.1).

<Button color="primary">Solid</Button>
<Button color="secondary">Solid</Button>
<Button color="neutral">Solid</Button>
<Button color="success">Solid</Button>
<Button color="info">Solid</Button>
<Button color="warning">Solid</Button>
<Button color="error">Solid</Button>
<!-- Every colour works with every variant. -->
ColourIntentFor example
primaryThe main action, in your brand colourSave, Publish, Continue
secondaryA second brand colour, for actions you want to set apartUpgrade, Try Pro
neutralEverything else, and the defaultCancel, Back, Edit, Filter
successCompletes something in a positive wayApprove, Mark as done
infoHelps or explains somethingTake the tour, Show details
warningGoes ahead despite a riskPublish anyway, Override
errorDestroys something, or can't be undoneDelete, Remove, Revoke

Note that secondary is a colour rather than a rank. For a secondary action, it's best to lower the variant instead, for example with variant="outline" next to a solid primary button. Every colour reaches a contrast of 4.5:1 in every variant, at rest, on hover and when pressed, in both light and dark mode (you'll find the details under tokens).

Sizes

The size prop scales the height, text, icons, padding and corner radius together, and md is the default. We recommend sticking to one size per region of the page, and changing it for the whole region rather than for individual buttons.

<Button size="xs" color="primary" variant="soft">
  <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 -->
SizeMin. heightTextIconPaddingGapRadius
xs24px12px14px8px4px6px
sm32px14px16px12px8px6px
md36px14px16px16px8px8px
lg40px16px20px20px8px8px
xl48px16px20px24px8px12px

Sizes are set in rem, with spacing on a 4px grid, and the table shows them at the default text size of 16px. Every size meets the 24 × 24px minimum from WCAG 2.5.8, and 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 components like PlusIcon) and place the icons in the leading and trailing slots.

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

Every state is designed, and each one is styled from the attribute that exposes it. That way, what people see and what assistive technologies report can never drift apart, and a button can't look disabled without actually being disabled.

At rest (try hovering, pressing or tabbing to it)
Disabled
Disabled, but still focusable
Loading
Pressed: false
Expanded: false
<Button color="primary">Save</Button>
<Button color="primary" disabled>Save</Button>
<Button color="primary" disabled 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>
StateSelectorLooks
Hover:hover, inside @media (hover: hover)An 8% state layer
Active:activeA 12% state layer
Focus:focus-visibleA 2px outline, 2px from the edge, in --color-focus
Disabled:disabled or [aria-disabled="true"]50% opacity, a not-allowed cursor and no state layer
Loading[data-loading]A spinner over the content and a progress cursor
Pressed[aria-pressed="true"]A 12% state layer
Expanded[aria-expanded="true"]A 12% state layer

On hover and press, the button lays its label's own colour over the background at 8% and 12%, much like the state layers in Material Design. A single rule covers all 42 combinations of colour and variant, and it keeps contrast predictable, since the tokens still pass 4.5:1 with the layers applied.

None of the states change the button's size or position, so there's no bolder label on hover and no border that appears on focus. A target that moves under your pointer is easy to miss. You'll find how each state behaves under Every state.

Hierarchy and placement

Try to keep a single primary action per view or group, and make it the only solid primary button. When every button competes for attention, none of them stands out. You can rank the rest with variant, using outline or soft for secondary actions and ghost or link for tertiary ones.

Publish this post?

It goes live for all subscribers right away.

<Button variant="ghost">Cancel</Button>
<Button variant="outline">Schedule…</Button>
<Button color="primary">Publish post</Button>
  • Keep the order the same across your product. In our examples, the primary action comes last, where the eye ends up, which is on the right in left-to-right layouts and on the left in right-to-left ones.
  • Keep the DOM order and the visual order the same. Properties like flex-direction: row-reverse and order move buttons around visually but not for the keyboard, which then jumps around unexpectedly (WCAG 1.3.2, 2.4.3).
  • Leave 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 tokens per colour from your theme, plus one focus colour that every component shares:

TokenUsed for
--color-{color}The solid fill, and the tint of soft and subtle
--color-{color}-contrastThe label and icons on top of the fill
--color-{color}-textThe label and icons on the page, for outline, soft, subtle, ghost and link
--color-focusThe focus ring, which is the same for every component

Everything else is derived from these. The soft variant mixes 12% of the fill into the page, while borders and state layers mix in the label colour. Our reference tokens come from the Tailwind CSS palette and pass 4.5:1 for every colour and variant, at rest, on hover and when pressed, on both white and zinc-900. If you change them, make sure to check the same pairs.

:root {
  --color-primary: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935));
  --color-primary-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823));
  --color-primary-text: light-dark(oklch(45.7% 0.24 277.023), oklch(78.5% 0.115 274.713));
  /* …secondary, neutral, success, info, warning and error */
  --color-focus: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935));
}

If you need to tune a single button, you can set its own variables, such as --button-height, --button-padding or --button-radius:

<Button color="primary" style="--button-radius: 999px">Get started</Button>

UX

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

Accessible

Use the native element

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

<!-- Do: you get the role, focus, keyboard and disabled state for free -->
<button type="button" @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>

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

Contrast and size

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

Show focus

When you reach a button with the keyboard, it shows a 2px outline, 2px outside its edge, in the same focus colour as every other component (WCAG 2.4.7). 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).

Don't nest interactive elements

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

Predictable

Run actions on click

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

Keyboard

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

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

Move focus where people expect it

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

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

Write labels that say what happens

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

Make destructive actions recoverable

Delete “Marketing site”?

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

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

Don't submit forms by accident

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

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

Every state

Disabled

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

  • disabled sets the native attribute. The button leaves the tab order, and screen readers announce it as dimmed or unavailable when they read through the page.
  • disabled focusable-when-disabled 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.

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.

<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>
  • 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, Space and even Enter in a form field are ignored until loading ends, which means no double submissions and no double charges.
  • It's announced. When loading starts, the Button announces its loading-label through a polite live region. Announcing the outcome is up to the page, with a status message like the one above, or an alert for errors (WCAG 4.1.3).

Toggle buttons

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

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

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

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

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

Adaptive

Pointer, touch and pen

  • Hover styles only apply to pointers that can actually hover (@media (hover: hover)), otherwise a tap would leave the button looking hovered.
  • On touch screens, every button gets a hit area of at least 44 × 44px, drawn by an invisible ::before, without changing how big it looks.
  • touch-action: manipulation stops a quick second tap from zooming the page, and a transparent -webkit-tap-highlight-color 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), the spinner pulses instead of turning, and nothing else moves (WCAG 2.3.3).

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, using GrayText when it's disabled and Highlight when it's pressed or expanded.

Colour scheme

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

Text size and spacing

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

Languages and direction

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

German, in a narrow column
Arabic, from right to left
<div style="width: 10rem">
  <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>

To flip the icons that point along the reading direction, a bit of CSS is all you need: .flip-in-rtl:dir(rtl) { scale: -1 1; }.

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.

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

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

Consistent

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

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

The defaults are variant="solid", color="neutral", size="md" and type="button". We made neutral the default so that a primary button is always a conscious decision, made once per view.

Type-safe

Props, events and slots are declared with defineProps, defineEmits and defineSlots, so your editor can autocomplete them and vue-tsc catches mistakes like variant="primary".

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

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

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

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 template ref's $el points to the same element, so there's no wrapper to reach through.

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

Passing an href renders an <a>. For links that go through your router, you can use its custom slot, which gives you the href and a navigate handler. The handler leaves modified clicks, like Ctrl-click to open a new tab, to the browser:

<RouterLink v-slot="{ href, navigate }" to="/settings" custom>
  <Button :href="href" @click="navigate">Settings</Button>
</RouterLink>

NuxtLink has the same custom slot. 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.
  • A group of buttons is simply a flex container with a gap. A toolbar, where the arrow keys move between buttons, is a separate pattern.

Controllable

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

StateHow you control it
Disableddisabled, and focusable-when-disabled
Loadingloading, set when the action starts (see Loading)
Pressed:aria-pressed="on"
Expanded:aria-expanded="open"
FocusA template ref, with button.value?.$el.focus()

Reference implementation

Our Vue 3 reference implementation meets 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. announce.ts is shared by every component that talks to screen readers, and tokens.css belongs in your theme.

<script setup lang="ts">
import { computed, onMounted, useTemplateRef, watch } from "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>
    <svg v-if="loading" data-slot="spinner" viewBox="0 0 16 16" aria-hidden="true">
      <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="2" opacity="0.25" />
      <path d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
    </svg>
  </component>
</template>

<style scoped>
.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);
  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 {
    text-decoration-line: underline;
  }
}
.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;
  text-decoration-line: none;
}
.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;
}
[data-slot="spinner"] {
  position: absolute;
  inset: 0;
  width: var(--button-icon-size);
  height: var(--button-icon-size);
  margin: auto;
  animation: button-spin 0.8s linear infinite;
}
@keyframes button-spin {
  to {
    rotate: 1turn;
  }
}
@keyframes button-pulse {
  50% {
    opacity: 0.4;
  }
}

/* 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 reduced motion, the spinner pulses instead of turning. */
@media (prefers-reduced-motion: reduce) {
  [data-slot="spinner"] {
    animation: button-pulse 1.6s ease-in-out infinite;
  }
}

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

AX

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

Semantic

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

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

A <div> with a click handler has no role, so neither agents nor screen readers see it as a button. In a way, semantics are the agent's API. With a role, a name and states, an agent can find the button with getByRole("button", { name: "Save changes" }) and know what pressing it will do.

Described

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

component: Button
summary: Runs an action in place. Renders a link when given an href.
standard: https://opencomponents.dev/docs/components/button
element: button # a, with href
role: button # link, with href
props:
  variant: { type: [solid, outline, soft, subtle, ghost, link], default: solid }
  color: { type: [primary, secondary, neutral, success, info, warning, error], default: neutral }
  size: { type: [xs, sm, md, lg, xl], default: md }
  label: { type: string }
  iconOnly: { type: boolean, default: false }
  type: { type: [button, submit, reset], default: button }
  href: { type: string }
  disabled: { type: boolean, default: false }
  focusableWhenDisabled: { type: boolean, default: false }
  loading: { type: boolean, default: false }
  loadingLabel: { type: string, default: Loading }
slots:
  default: The label.
  leading: An icon before the label, and the icon of an icon-only button.
  trailing: An icon after the label.
events:
  click: MouseEvent. Never emitted 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
rules: https://opencomponents.dev/docs/components/button#checklist

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

Deterministic

The same props always render the same markup. For example, <Button color="primary" loading>Save</Button> renders the following, leaving out Vue's comments and data-v-* scoping attributes:

<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 data-slot="spinner" viewBox="0 0 16 16" aria-hidden="true">…</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.
  • 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.
  • Nothing is guessed either. Icon-only is a prop, rather than something inferred from the content.

Verifiable

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

Unit tests

These are the tests for our reference implementation. They run on Vitest (with environment: "jsdom" and globals: true), using @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom:

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 } 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("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("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("passes ARIA states through, for toggle and menu buttons", () => {
    render(Button, { attrs: { "aria-pressed": "true" }, slots: { default: "Mute" } });

    expect(screen.getByRole("button", { name: "Mute", pressed: true })).toBeInTheDocument();
  });

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

In the browser

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

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

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

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

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

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

During development

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

Prompts

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

Build a Button component for our 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, and cite the rule ID for any
rule you can't meet.

And to review one:

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

For each rule in its checklist, 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 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.

UI rules

RuleLevelRequirementCheck
button/intent-colorsMustColours describe the action's intent rather than a hue, and never carry that meaning on their ownReview
button/one-primaryShouldThere's one solid primary button per view or group, and the rest are ranked with variantReview
button/contrastMustThe 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 pageaxe color-contrast
button/focus-visibleMustKeyboard focus shows a 2px outline, offset from the button, on :focus-visibleKeyboard
button/target-sizeMustThe button is at least 24 × 24px, with a 44 × 44px hit area on touch screensaxe target-size
button/resizableMustThe button is sized with min-height and padding in rem, and its label wraps instead of being clipped or truncated200% zoom
button/stable-sizeMustNo state changes the button's size or positionVisual regression test
button/state-selectorsShouldStates are styled from the attributes that expose themReview
button/hover-capableShouldHover styles only apply inside @media (hover: hover)Review
button/forced-colorsMustThe button's boundary, focus, disabled and pressed states stay visible in forced colours modeEmulation
button/reduced-motionMustNothing turns or slides when prefers-reduced-motion: reduce is setEmulation

UX rules

RuleLevelRequirementCheck
button/native-elementMustIt's a native <button>, or an <a href> for navigation, but never a <div> or a <span>Unit test
button/links-navigateMustNavigation always uses a link, so a button never changes the URLReview
button/accessible-nameMustEvery button has a name, and an icon-only button has a visually hidden labelaxe button-name, unit test
button/label-in-nameMustAn aria-label or aria-labelledby includes the visible label, ideally at the startaxe label-content-name-mismatch
button/verb-labelsShouldLabels start with a verb and name the outcome, and end with … when more input followsReview
button/keyboardMustEnter and Space activate a button, while Enter follows a linkUnit test
button/activate-on-clickMustActions run on click, never on mousedown, pointerdown or touchstartReview
button/no-nestingMustThere's nothing interactive inside a button, and no button inside a linkaxe nested-interactive
button/disabled-inertMustA disabled or loading button does nothing, so there's no event, no submission and no navigationUnit test
button/explain-disabledShouldAn enabled button with validation is preferred over a disabled one, and a disabled one explains whyReview
button/keep-focusMustA focused button is never natively disabled, which is why loading uses aria-disabledUnit test
button/loadingMustLoading keeps the button's size and name, blocks repeated activation and is announcedUnit test
button/announce-outcomeShouldThe page announces the action's outcome with a status messageScreen reader
button/toggle-pressedMustA toggle button sets aria-pressed and keeps its labelUnit test
button/popup-expandedMustA button that opens a popup sets aria-haspopup and aria-expandedUnit test
button/focus-afterShouldAfter activation, focus moves as the APG describes, and never falls back to the top of the pageKeyboard
button/destructiveShouldA destructive action uses error, names what it destroys, and can either be undone or asks for confirmationReview

DX rules

RuleLevelRequirementCheck
button/shared-vocabularyMustIt uses the shared names: variant, color, size, label, disabled, loading, leading, trailing and clickReview
button/typedMustProps, events and slots are typed, with unions rather than stringvue-tsc
button/explicit-typeMusttype defaults to "button", and submit buttons say type="submit" explicitlyUnit test
button/root-elementMustThe root element is the button itself, so attributes, listeners and refs land on itUnit test
button/statelessShouldEvery state is a prop or an attribute that the parent controlsReview

AX rules

RuleLevelRequirementCheck
button/role-and-nameMustIt can be found by its role and name alone, with getByRole("button", { name })Unit test
button/deterministic-domMustThe same props render the same markup, and states change attributes, never elementsUnit test
button/data-attributesShouldProps are mirrored as data-* attributes, and parts are marked with data-slotUnit test
button/dev-warningsShouldIt warns during development when a button has no accessible nameUnit test

FAQ

Buttons do things, while links go places. You can decide what's best as follows: if people could want to open it in a new tab, bookmark it or share it, it's a link.

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

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

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

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

Sources

Copyright © 2026