Button
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.
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 |
| 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 |
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
- Container — The
<button>, or the<a href>when it navigates. It carries the variant, colour and size. - Leading icon — Optional, and hidden from assistive technologies, since the label already names the action.
- Label — The accessible name. When the button is icon-only, it's visually hidden but never removed.
- Trailing icon — Optional. It hints at what happens next, like a menu opening or the flow moving on.
- 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>
| 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 | Text that's underlined on hover | 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.
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. -->
| 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 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 -->
| 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, 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.
<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>
| State | Selector | Looks |
|---|---|---|
| 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 |
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.
<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-reverseandordermove 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:
| 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. 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
labelandicon-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 foraria-label. - If you name a button with
aria-labeloraria-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
| 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 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
<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:
disabledsets 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-disabledsetsaria-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 ofdisabled. 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,Spaceand evenEnterin 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-labelthrough 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.
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. 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: manipulationstops a quick second tap from zooming the page, and a transparent-webkit-tap-highlight-colorremoves 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).
<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:
| 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 |
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".
| 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 | Emitted 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:
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>
Links and routers
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-expandedandaria-controlson 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:
| State | How you control it |
|---|---|
| Disabled | disabled, and focusable-when-disabled |
| Loading | loading, set when the action starts (see Loading) |
| Pressed | :aria-pressed="on" |
| Expanded | :aria-expanded="open" |
| Focus | A 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>
/**
* 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);
}
/*
* 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));
}
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-coloranddata-sizeare always there, whiledata-icon-onlyanddata-loadingonly appear when they're set. - Parts are marked with
data-slotand always come in the same order:leading,label,trailingandspinner. - 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:
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
| Rule | Level | Requirement | Check |
|---|---|---|---|
button/intent-colors | Must | Colours describe the action's intent rather than a hue, and never carry that meaning on their own | Review |
button/one-primary | Should | There's one solid primary button per view or group, and the rest are ranked with variant | Review |
button/contrast | Must | 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 | axe color-contrast |
button/focus-visible | Must | Keyboard focus shows a 2px outline, offset from the button, on :focus-visible | Keyboard |
button/target-size | Must | The button is at least 24 × 24px, with a 44 × 44px hit area on touch screens | axe target-size |
button/resizable | Must | The button is sized with min-height and padding in rem, and its label wraps instead of being clipped or truncated | 200% zoom |
button/stable-size | Must | No state changes the button's size or position | Visual regression test |
button/state-selectors | Should | States are styled from the attributes that expose them | Review |
button/hover-capable | Should | Hover styles only apply inside @media (hover: hover) | Review |
button/forced-colors | Must | The button's boundary, focus, disabled and pressed states stay visible in forced colours mode | Emulation |
button/reduced-motion | Must | Nothing turns or slides when prefers-reduced-motion: reduce is set | Emulation |
UX rules
| Rule | Level | Requirement | Check |
|---|---|---|---|
button/native-element | Must | It's a native <button>, or an <a href> for navigation, but never a <div> or a <span> | Unit test |
button/links-navigate | Must | Navigation always uses a link, so a button never changes the URL | Review |
button/accessible-name | Must | Every button has a name, and an icon-only button has a visually hidden label | axe button-name, unit test |
button/label-in-name | Must | An aria-label or aria-labelledby includes the visible label, ideally at the start | axe label-content-name-mismatch |
button/verb-labels | Should | Labels start with a verb and name the outcome, and end with … when more input follows | Review |
button/keyboard | Must | Enter and Space activate a button, while Enter follows a link | Unit test |
button/activate-on-click | Must | Actions run on click, never on mousedown, pointerdown or touchstart | Review |
button/no-nesting | Must | There's nothing interactive inside a button, and no button inside a link | axe nested-interactive |
button/disabled-inert | Must | A disabled or loading button does nothing, so there's no event, no submission and no navigation | Unit test |
button/explain-disabled | Should | An enabled button with validation is preferred over a disabled one, and a disabled one explains why | Review |
button/keep-focus | Must | A focused button is never natively disabled, which is why loading uses aria-disabled | Unit test |
button/loading | Must | Loading keeps the button's size and name, blocks repeated activation and is announced | Unit test |
button/announce-outcome | Should | The page announces the action's outcome with a status message | Screen reader |
button/toggle-pressed | Must | A toggle button sets aria-pressed and keeps its label | Unit test |
button/popup-expanded | Must | A button that opens a popup sets aria-haspopup and aria-expanded | Unit test |
button/focus-after | Should | After activation, focus moves as the APG describes, and never falls back to the top of the page | Keyboard |
button/destructive | Should | A destructive action uses error, names what it destroys, and can either be undone or asks for confirmation | Review |
DX rules
| Rule | Level | Requirement | Check |
|---|---|---|---|
button/shared-vocabulary | Must | It uses the shared names: variant, color, size, label, disabled, loading, leading, trailing and click | Review |
button/typed | Must | Props, events and slots are typed, with unions rather than string | vue-tsc |
button/explicit-type | Must | type defaults to "button", and submit buttons say type="submit" explicitly | Unit test |
button/root-element | Must | The root element is the button itself, so attributes, listeners and refs land on it | Unit test |
button/stateless | Should | Every state is a prop or an attribute that the parent controls | Review |
AX rules
| Rule | Level | Requirement | Check |
|---|---|---|---|
button/role-and-name | Must | It can be found by its role and name alone, with getByRole("button", { name }) | Unit test |
button/deterministic-dom | Must | The same props render the same markup, and states change attributes, never elements | Unit test |
button/data-attributes | Should | Props are mirrored as data-* attributes, and parts are marked with data-slot | Unit test |
button/dev-warnings | Should | It warns during development when a button has no accessible name | Unit test |
FAQ
Button or link?
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… | 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 | <button aria-pressed> |
| Opens a menu | Menu 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>
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.<button type="submit"> does everything it does, and more.<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.aria-label when there's no other choice, and then make sure it includes the visible text.Sources
- WAI-ARIA Authoring Practices: Button, Menu Button and Dialog
- Web Content Accessibility Guidelines (WCAG) 2.2
- HTML Standard: the button element
- CSS Color Adjustment: forced colors mode
- Material Design 3: state layers
- React Aria: Button, for the pending state
- Primer: Button, for the loading state
- Base UI: Button, for focusable disabled buttons
- Styleframe: Button, for the colour and variant vocabulary