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

native-html beginner wcag-2.2-aa

// 01 · live demo

Live Demo

Six button variants showing the most common real-world states.

Screen reader announces "Settings, button"

The first button is removed from the tab sequence; some screen readers don't announce it at all. The second is still focusable and discoverable — try tabbing to it. Click it for an explanation of why it's disabled.

Each click flips aria-pressed. Screen readers announce "pressed" or "not pressed".

Click to simulate an async action. While busy, the button is disabled and aria-busy="true".

Click me

No role, no tabindex, no keyboard support, no focus indicator. Keyboard users can't reach it; screen readers announce nothing meaningful; voice-control users can't say "click button". A real <button> gives all of these for free.

// 02 · the code

The Code

HTML — All variants
<!-- 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>
CSS — focus, hover, disabled, target size
.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; }
}
JavaScript — Toggle and loading state
// 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 tabindex needed.
  • 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:

  • disabled removes 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 fire click as 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.
Anchor styled as a button is not the same An <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-pressed exposes 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.
Icon-only button with no 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.
Removing the focus outline without a replacement 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.
Missing 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.
Using 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
The golden rule Always reach for <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.