// pattern
Accessible Button Pattern
The button is the most fundamental interactive element on the web. Get it right and most users never think about it; get it wrong and you've broken keyboard, screen reader, and touch users at once.
// 01 · live demo
Live Demo
Six button variants showing the most common real-world states.
Three core variants:
Icon-only with aria-label:
disabled vs aria-disabled:
Toggle with aria-pressed:
Loading state with aria-busy:
Inaccessible version (<div> with click handler):
// 02 · the code
The Code
<!-- type="button" prevents accidental form submission
when the button lives inside a <form>. -->
<button type="button" class="btn btn--primary">
Save changes
</button>
<button type="button" class="btn btn--secondary">
Cancel
</button>
<button type="button" class="btn btn--ghost">
Learn more
</button>
<!-- Icon-only: aria-label gives the accessible name.
The svg is hidden from AT to avoid duplicate output. -->
<button type="button" class="btn btn--icon" aria-label="Settings">
<svg aria-hidden="true"><!-- gear icon --></svg>
</button>
<!-- disabled: removes the button from the tab order.
Use when interaction is genuinely impossible. -->
<button type="button" class="btn btn--primary" disabled>
Submit
</button>
<!-- aria-disabled: stays focusable so users can
read why it's disabled. JS must block activation. -->
<button type="button" class="btn btn--primary"
aria-disabled="true">
Submit
</button>
<!-- Toggle: aria-pressed reflects state.
Update both the attribute and any visual cue in JS. -->
<button type="button" class="btn btn--toggle"
aria-pressed="false">
Bold
</button>
<!-- Loading: aria-busy + disabled while in flight. -->
<button type="button" class="btn btn--primary">
<span class="spinner" aria-hidden="true"></span>
<span class="btn__label">Save changes</span>
</button>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
/* SC 2.5.8 (Target Size Minimum, AA): 24x24 minimum.
44x44 is the recommended comfortable target. */
min-height: 2.75rem;
min-width: 2.75rem;
padding: 0.5rem 1rem;
border: 1px solid transparent;
border-radius: 0.25rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
/* SC 2.4.7 Focus Visible + 2.4.13 Focus Appearance:
2px outline at 3:1 contrast, offset so it isn't
clipped by the button's own border. */
.btn:focus-visible {
outline: 2px solid #5b2a86;
outline-offset: 2px;
}
.btn:hover:not([disabled]):not([aria-disabled="true"]) {
background: #4a1f6e;
}
/* Both disabled mechanisms get the same visual cue
so users can't tell whether a button is "really"
disabled or just aria-disabled. */
.btn[disabled],
.btn[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.55;
}
/* Pressed state for toggle buttons. The visual change
reinforces aria-pressed for sighted users. */
.btn--toggle[aria-pressed="true"] {
background: #5b2a86;
color: #ffffff;
}
/* Spinner only renders during aria-busy. */
.btn[aria-busy="true"] .spinner {
display: inline-block;
}
@media (prefers-reduced-motion: reduce) {
.spinner { animation-duration: 2s; }
}
// Toggle button: flip aria-pressed on every click.
document.querySelectorAll('[aria-pressed]').forEach(function (btn) {
btn.addEventListener('click', function () {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
});
});
// aria-disabled: block activation in JS because the
// browser does NOT block clicks for aria-disabled.
document.querySelectorAll('[aria-disabled="true"]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
// Optionally: explain why it's disabled.
});
});
// Loading state: set aria-busy + disabled, then restore.
function runWithLoading(button, work) {
button.setAttribute('aria-busy', 'true');
button.disabled = true;
Promise.resolve(work()).finally(function () {
button.removeAttribute('aria-busy');
button.disabled = false;
});
}
// 03 · why these decisions
Why These Decisions
Why <button> instead of <a> or <div>?
The native <button> ships with a stack of behavior you'd otherwise have to rebuild — and rebuild correctly:
- Focus management: it's in the tab sequence by default, no
tabindexneeded. - Keyboard activation: both Enter and Space trigger
click. Anchors only fire on Enter;<div>s fire on neither. - Implicit role: assistive technology announces "button" with no ARIA required.
- Form integration: participates in form submission and the disabled-form-controls API.
- Voice control: "click Save" works because the platform sees a real button.
Use <a> when navigating to a URL. Use <button> when triggering an action on the current page. The user-facing distinction is whether the address bar should change.
Why type="button" matters
The default type for a <button> inside a <form> is submit. A "Cancel" or "Add row" button without an explicit type will submit the form when activated — sometimes silently, sometimes destructively. Always declare type="button" for any button that isn't the form's primary submit control.
disabled vs aria-disabled="true"
Both communicate "this button is currently inactive", but they have very different keyboard and AT behavior:
disabledremoves the button from the tab order entirely. Some screen readers skip it on virtual cursor browse. The button cannot be activated, focused, or discovered by keyboard users.aria-disabled="true"keeps the button focusable and discoverable. It still receives keyboard focus and is announced by AT. You must block activation in JavaScript yourself — the browser will fireclickas normal.
Rule of thumb: use disabled when the user has no business knowing the control exists. Use aria-disabled when the user might want to discover the button and learn why it's currently off — for example, a "Submit" button that only enables once the form is valid.
Why icon-only buttons need aria-label
An <svg> alone provides no accessible name. <button><svg/></button> is announced as just "button" — useless. Add aria-label="Settings" on the button (and aria-hidden="true" on the svg so the icon doesn't end up double-announced if it ever gains a title). Visible text inside a visually-hidden span works equally well and is easier to localize.
Why aria-pressed for toggles, not just a CSS class
A class change like .is-active is invisible to screen readers. Sighted users see the highlighted "Bold" button; AT users hear "Bold, button" — same as before the click. aria-pressed is the only way to communicate the on/off state, and it's announced as "pressed" or "not pressed" in addition to the role. Always update both the attribute and your visual style; pair them via the CSS attribute selector so they can never drift.
Why aria-busy during loading
Setting aria-busy="true" tells assistive technology that the element is in the process of updating. Combined with disabled (or aria-disabled), it prevents double-clicks while making the in-flight state perceivable. When the action completes, remove both — the button becomes responsive again. Don't rely solely on a spinner; spinners are invisible to screen reader users.
Why the focus ring must stay visible
WCAG 2.4.7 Focus Visible (Level AA) requires a visible focus indicator on every focusable control. The infamous outline: none reset breaks this for every keyboard, switch, and voice-control user on the page. Use :focus-visible if you want to suppress the ring when activated by mouse, but always keep it for keyboard. Aim for at least a 2px-thick indicator with 3:1 contrast against the adjacent surface (SC 1.4.11).
// 04 · keyboard interaction
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Moves focus to the button (skips it when disabled). |
| Shift + Tab | Moves focus to the previous focusable element. |
| Enter | Activates the button (fires click). |
| Space | Activates the button on key-up (fires click). |
| Esc | No effect on a standalone button. Modal dialogs handle Esc themselves; the button doesn't. |
<a> styled to look like a button is activated only by Enter, never Space. Users with motor impairments who rely on Space will think the control is broken. This is one of the strongest arguments for using <button> whenever the control performs an action rather than navigating.
// 05 · wcag 2.2 success criteria
WCAG 2.2 Success Criteria
This pattern satisfies the following WCAG 2.2 success criteria:
- 1.4.11 Non-text Contrast Level AA — The focus indicator and the disabled-state border have at least 3:1 contrast against adjacent surfaces.
- 2.1.1 Keyboard Level A — The native button is fully operable from the keyboard with no custom code.
- 2.4.7 Focus Visible Level AA — A clear, persistent focus ring appears on keyboard focus.
- 2.4.11 Focus Not Obscured (Minimum) Level AA — The focus ring's offset keeps the indicator visible even when the button sits next to sticky headers or footers (new in WCAG 2.2).
- 2.4.13 Focus Appearance Level AAA — The 2px outline meets the recommended thickness and contrast.
- 2.5.8 Target Size (Minimum) Level AA — The button's minimum 24×24 hit area is satisfied; the demo uses a comfortable 44×44 (new in WCAG 2.2).
-
4.1.2 Name, Role, Value
Level A
— The native role is "button"; the accessible name comes from text content or
aria-label;aria-pressedexposes the toggle state.
// 06 · screen reader behavior
Screen Reader Behavior
Plain button
NVDA, JAWS, and VoiceOver all announce the accessible name plus the role:
- NVDA / JAWS: "Settings, button"
- VoiceOver (macOS): "Settings, button"
Disabled (disabled attribute)
- NVDA: "Settings, button, unavailable"
- JAWS: "Settings, button, unavailable"
- VoiceOver: "Settings, dimmed, button"
Some screen readers, in some browse modes, skip disabled buttons entirely — meaning users may not even know the control exists.
aria-disabled="true"
Without any other ARIA, an aria-disabled="true" button is still announced as a normal button — "Settings, button" — and remains in the tab order. That's the point: users can find it, focus it, and read your "why is this disabled?" hint. If you want the disabled state announced, AT will pick it up from the aria-disabled attribute (announced as "dimmed" or "unavailable" on most modern screen readers).
Toggle button (aria-pressed)
aria-pressed="true": "Bold, toggle button, pressed"aria-pressed="false": "Bold, toggle button, not pressed"
The role changes from "button" to "toggle button" the moment aria-pressed is present, so the user understands this control has on/off state before activating it.
Loading (aria-busy="true")
The busy state is announced alongside the role, e.g. "Save changes, button, busy". Combined with disabled, the user hears that the button is unavailable and currently working — exactly the mental model you want.
// 07 · common mistakes
Common Mistakes
<div onclick> with no role and no keyboard support
The single most common a11y bug on the web. A clickable <div> isn't focusable, isn't announced as a button, doesn't respond to Enter or Space, and isn't reachable by voice control. Replace it with <button type="button"> — every problem disappears at once.
aria-label
<button><svg/></button> is announced as bare "button". AT users have no idea what it does. Add aria-label="Settings" (or visually-hidden text) and aria-hidden="true" on the svg.
button:focus { outline: none; } is the most damaging two-word a11y bug in CSS. Keyboard users lose all visual feedback about where they are on the page. If you must restyle, use :focus-visible with a 2px outline and 3:1 contrast — never simply remove it.
type="button" inside a form
The default button type in a form is submit. A "Cancel" or "Add row" button without type="button" will submit the form when clicked or pressed. The result: lost data, double-submits, broken validation. Make type="button" a habit.
disabled when users need to discover and learn why
A disabled "Submit" button on an invalid form vanishes from many screen reader users' radar. They tab through, never hear the button, and don't know the form has a submit step at all. Prefer aria-disabled="true" with a tooltip or live-region message explaining what they need to fix.
aria-pressed + class change but no real state in JS
If your toggle visually flips but you forget to update aria-pressed, screen reader users hear the same announcement on every click — "Bold, toggle button, not pressed" — even when the button is clearly active. Always pair the visual change with the attribute change in the same handler.
// 08 · native html vs aria
Native HTML vs ARIA
If you ever wonder whether a custom control can match the native one, this is the table.
| Aspect | Native <button> |
<div role="button" tabindex="0"> |
|---|---|---|
| In tab order by default | Yes | Only if tabindex="0" is added |
| Activates on Enter | Yes | Only with a custom keydown handler |
| Activates on Space | Yes | Only with a custom keyup handler (and you must preventDefault on keydown to stop scrolling) |
| Default styles in user-agent stylesheet | Yes (border, background, cursor) | None — looks like text |
Submits a form when type="submit" |
Yes | No, ever |
Honors disabled attribute |
Yes (browser blocks click + removes from tab order) | No — must implement manually |
| Screen reader role | "button" automatically | "button" only because of role="button" |
| Voice control ("click Save") | Works out of the box | Often unreliable; depends on heuristics |
| Browser parsing | Native interactive element | Generic container; tools like Lighthouse and axe flag it |
<button> first. Use <a> when the action is navigation. Reach for role="button" only when retrofitting a legacy component you genuinely cannot rewrite — and then accept that you'll need keyboard handlers, focus management, and disabled-state logic that the browser would have given you for free.