Accessibility Developer Kit · Guide 01 / 20

Accessible Button

Build buttons that work for every user — mouse, keyboard, screen reader and voice control. Covers 4 real-world variants in Vanilla HTML/CSS/JS and React, with keyboard, screen reader and testing guidance.

  • WCAG: 2.1.1 · 2.4.7 · 2.5.5 · 4.1.2
  • Level: Beginner
  • Icons: lucide-react
1

What we are building

A reusable button pattern that works for every user, in every stack.

What we are building

A button looks simple, but it is the component with the highest number of accessibility defects on the web. We will build one correctly in two stacks (Vanilla and React), and show you how to repair the three common markup variants you will inherit from legacy codebases.

2

The 4 real-world variants

You will see all of these in production code. Each tab shows the inaccessible version and the fix.

The 4 real-world variants

Variant A

Native <button>

Preferred
Live example

Clicked 0 times

HTML · Accessible
<!-- Native button: keyboard, screen-reader, focus all work for free -->
<button type="button" onclick="handleSave()">
  Save Changes
</button>

If you control the markup, this is always the right choice. Browser provides focus, keyboard activation, and screen reader semantics for free.

Variant B

<a> as a button

Retrofit OK
Live example
Submit

Activated 0 times

HTML · Accessible
<!-- Add role="button" AND handle Space key (links fire on Enter only) -->
<a
  href="#"
  role="button"
  class="btn"
  onclick="handleSubmit(event)"
  onkeydown="if (event.key === ' ') { event.preventDefault(); handleSubmit(event); }"
>
  Submit
</a>

Common in Shopify themes, WordPress and legacy CMSes. Add role='button' and a Space-key handler (anchors fire on Enter only).

Variant C

<div> as a button

Last resort
Live example
Submit

Activated 0 times

HTML · Accessible
<!-- Div turned into a button via ARIA, tabindex, and keyboard handlers -->
<div
  role="button"
  tabindex="0"
  class="btn"
  onclick="handleSubmit()"
  onkeydown="if (event.key === 'Enter' || event.key === ' ') {
    event.preventDefault();
    handleSubmit();
  }"
>
  Submit
</div>

A div has no role, no tab stop and no keyboard events. Add all four: role, tabIndex, Enter + Space handlers, and aria-disabled.

Variant D

Icon-only button

Preferred
Live example

Deleted 0 · Liked: no

HTML · Accessible
<!-- aria-label on the button, aria-hidden on the decorative svg -->
<button type="button" aria-label="Delete item">
  <svg aria-hidden="true" focusable="false">
    <!-- trash icon -->
  </svg>
</button>

SVG must be aria-hidden. The button carries the accessible name via aria-label. Use aria-pressed for toggles.

3

Flagship implementation

The native button, done right. Everything below assumes this base.

Flagship implementation

AccessibleButton.jsx
import React from "react";
import { Loader2 } from "lucide-react";
import "./AccessibleButton.css";

/**
 * AccessibleButton
 *  - children:   button label (text or icon + text)
 *  - onClick:    click handler
 *  - type:       "button" | "submit" | "reset"
 *  - variant:    "primary" | "secondary" | "danger" | "icon"
 *  - disabled:   boolean
 *  - isLoading:  boolean (sets aria-busy, disables, shows spinner)
 *  - ariaLabel:  required for variant="icon"
 *  - icon:       a lucide-react icon component
 */
export default function AccessibleButton({
  children,
  onClick,
  type = "button",
  variant = "primary",
  disabled = false,
  isLoading = false,
  ariaLabel,
  icon: Icon,
  ...rest
}) {
  const isIconOnly = variant === "icon";

  if (
    process.env.NODE_ENV !== "production" &&
    isIconOnly &&
    !ariaLabel
  ) {
    console.warn("AccessibleButton: variant='icon' requires ariaLabel.");
  }

  return (
    <button
      type={type}
      onClick={onClick}
      disabled={disabled || isLoading}
      aria-label={ariaLabel}
      aria-busy={isLoading || undefined}
      className={`btn btn--${variant}`}
      {...rest}
    >
      {isLoading && (
        <Loader2 aria-hidden="true" size={16} className="btn__spinner" />
      )}
      {!isLoading && Icon && <Icon aria-hidden="true" size={16} />}
      {!isIconOnly && <span>{children}</span>}
    </button>
  );
}
Usage · Demo.jsx
import { Save, Trash2, Download } from "lucide-react";
import AccessibleButton from "./AccessibleButton";

export default function Demo() {
  const [downloading, setDownloading] = useState(false);

  return (
    <>
      {/* Text only */}
      <AccessibleButton onClick={handleSave}>
        Save Changes
      </AccessibleButton>

      {/* Icon + text */}
      <AccessibleButton icon={Save} onClick={handleSave}>
        Save Changes
      </AccessibleButton>

      {/* Icon only — ariaLabel REQUIRED */}
      <AccessibleButton
        variant="icon"
        icon={Trash2}
        ariaLabel="Delete item"
        onClick={handleDelete}
      />

      {/* Async with loading state */}
      <AccessibleButton
        icon={Download}
        isLoading={downloading}
        onClick={handleDownload}
      >
        {downloading ? "Downloading..." : "Download"}
      </AccessibleButton>
    </>
  );
}
4

Keyboard behavior

Unplug your mouse. Everything below must still work.

Keyboard behavior

KeyExpected result
TabButton receives visible focus
Shift + TabFocus returns to the previous element
EnterActivates the button (fires onClick)
SpaceActivates the button on key-up
5

Screen reader expectations

Exact wording varies by browser + SR combination. Test with at least two.

Screen reader expectations

  • Text button: “Save Changes, button”
  • Icon-only button: “Delete item, button”
  • Disabled: “Save Changes, dimmed, button” (VoiceOver) / “unavailable” (NVDA)
  • Loading (aria-busy): “Saving…, busy, button”
  • Toggle (aria-pressed): “Like, toggle button, pressed”
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
2.1.1 Keyboard (A)Button must be operable with Enter and Space.
2.4.7 Focus Visible (AA)Keyboard users must see where focus is (:focus-visible).
2.5.5 Target Size (AA, 2.2)Minimum 24×24 CSS px; 44×44 recommended.
4.1.2 Name, Role, Value (A)Must expose role=button and an accessible name.
1.4.11 Non-text Contrast (AA)Focus ring must have 3:1 contrast against background.
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

The pattern is always the same: render → axe check → simulate keyboard → assert. Only the imports change.

Jest + Testing Library + jest-axe

AccessibleButton.test.jsx
// Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleButton from "./AccessibleButton";

expect.extend(toHaveNoViolations);

test("has accessible name and button role", () => {
  render(<AccessibleButton>Save</AccessibleButton>);
  expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
});

test("activates on Enter and Space", async () => {
  const user = userEvent.setup();
  const onClick = jest.fn();
  render(<AccessibleButton onClick={onClick}>Go</AccessibleButton>);
  const btn = screen.getByRole("button", { name: "Go" });
  btn.focus();
  await user.keyboard("{Enter}");
  await user.keyboard(" ");
  expect(onClick).toHaveBeenCalledTimes(2);
});

test("has no axe violations", async () => {
  const { container } = render(<AccessibleButton>OK</AccessibleButton>);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library + vitest-axe

AccessibleButton.test.jsx
// Vitest + @testing-library/react + vitest-axe
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import AccessibleButton from "./AccessibleButton";

test("has accessible name and button role", () => {
  render(<AccessibleButton>Save</AccessibleButton>);
  expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
});

test("passes axe", async () => {
  const { container } = render(<AccessibleButton>OK</AccessibleButton>);
  expect(await axe(container)).toHaveNoViolations();
});

Playwright end-to-end + @axe-core/playwright

button.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("button page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-button");

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

  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("button is reachable and activatable with keyboard", async ({ page }) => {
  await page.goto("/en/accessible-button");
  await page.getByRole("button", { name: /save changes/i }).focus();
  await page.keyboard.press("Enter");
  await expect(page.getByText(/Clicked 1 time/i)).toBeVisible();
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Uses <button>, not div or anchor without href
  • Has explicit type="button"/"submit"/"reset"
  • Visible focus ring on :focus-visible
  • Icon-only buttons have aria-label (or visually-hidden text)
  • Decorative SVG inside has aria-hidden="true"
  • Minimum target size 24×24 px (prefer 44×44)
  • Disabled uses disabled or aria-disabled="true"
  • Loading uses aria-busy="true" with a visible indicator
  • Respects prefers-reduced-motion
  • Zero critical / serious axe violations
9

Common mistakes

The bugs we see on 90% of audits.

Common mistakes

  • Removing focus outline with no replacement. outline: none; is the top a11y bug on the web.
  • Using <a> for actions. Anchors are for navigation; buttons are for actions.
  • Forgetting aria-label on icon-only buttons. Screen readers announce “button” with no name.
  • Disabled buttons that are unfocusable.Users don’t know they exist. Prefer aria-disabled="true" so they stay in Tab order.
  • Double submission. Disable or set aria-busy during async actions.
  • Low contrast icons. SVG stroke must meet 3:1 against the button background.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo