React · accessibility engineering

React accessibility — a practical WCAG 2.1 AA guide

React is a component toolkit, not an accessibility framework. Most accessibility problems in React apps are caused by devs writing <div onClick> where a <button> belongs, or spreading ARIA attributes without understanding what they imply. This guide covers the patterns that come up in every real React codebase, focus management in a SPA, form associations, and the tooling that catches regressions before they ship.

Use the right element first

Before you reach for ARIA, reach for HTML. <button>is focusable by default, fires on Enter / Space, has a role of "button", and is announced by screen readers correctly. A <div onClick> has none of that.

// ❌ Inaccessible — not focusable, not keyboard-operable, wrong role
<div onClick={handleClick} className="btn">Submit</div>

// ✅ Use the native element
<button type="button" onClick={handleClick} className="btn">Submit</button>

// ✅ If you must use div (rare), you'd need all of this
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") handleClick();
  }}
  aria-pressed={pressed}
>
  Submit
</div>

Form inputs and labels

Every interactive input needs a programmatic label. useId() (React 18+) is the cleanest way to wire htmlFor to a server-safe unique id:

import { useId } from "react";

function EmailField({ error }) {
  const id = useId();
  const errorId = `${id}-error`;
  return (
    <>
      <label htmlFor={id}>Email address</label>
      <input
        id={id}
        type="email"
        required
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
      />
      {error && <p id={errorId} role="alert" className="text-red-600">{error}</p>}
    </>
  );
}

Placeholder text is not a label. Screen readers often don't announce placeholders; when the user starts typing, the placeholder disappears and the field loses context. Always pair placeholder with a visible or visually-hidden <label>.

Focus management across routes

When a user clicks a link in a traditional page, the browser moves focus to the top of the new page. In a React SPA (or Next.js App Router), focus stays where it was — which disorients keyboard and screen-reader users.

// Pattern: focus the h1 of the new page on route change
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";

export function PageFocusManager() {
  const pathname = usePathname();
  const firstMount = useRef(true);
  useEffect(() => {
    if (firstMount.current) {
      firstMount.current = false;
      return; // don't steal focus on initial load
    }
    const h1 = document.querySelector<HTMLHeadingElement>("h1[tabindex='-1']");
    h1?.focus();
  }, [pathname]);
  return null;
}

// In each page:
<h1 tabIndex={-1} className="outline-none">Pricing</h1>

Also include a skip link at the top of the document, visible only on focus:

<a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2">
  Skip to main content
</a>
<main id="main" tabIndex={-1}>…</main>

Dialogs, menus, and focus traps

Most WCAG violations cluster around modal interactions. The native <dialog> element handles focus trap, Escape key, and backdrop click out of the box. Use it instead of rolling your own:

function ConfirmDialog({ open, onClose, title, children }) {
  const ref = useRef<HTMLDialogElement>(null);
  useEffect(() => {
    if (!ref.current) return;
    if (open && !ref.current.open) ref.current.showModal();
    if (!open && ref.current.open) ref.current.close();
  }, [open]);
  return (
    <dialog
      ref={ref}
      onClose={onClose}
      aria-labelledby="dlg-title"
      className="rounded-lg p-6 backdrop:bg-black/50"
    >
      <h2 id="dlg-title">{title}</h2>
      {children}
    </dialog>
  );
}

If you need a custom dialog (e.g., for transition animations), libraries like @radix-ui/react-dialog and react-ariahandle focus trap, ARIA wiring, and Escape key correctly. Don't write that plumbing yourself.

Live regions and async status

When a form submission completes, a toast appears, or a search result updates — sighted users see it. Screen-reader users don't, unless you mark the container as an ARIA live region.

// Assertive — interrupts the screen reader immediately (errors)
<div role="alert" aria-live="assertive">
  {error && error.message}
</div>

// Polite — waits for a pause (status, toasts, save indicators)
<div role="status" aria-live="polite">
  {saving ? "Saving…" : "Saved ✓"}
</div>

Don't sprinkle aria-live everywhere — it causes announcement storms. One role="status" region per logical area is usually enough.

Testing — jest-axe + Playwright + axe-core

Two layers of testing catch different things:

  • Unit-level with jest-axe — render a component in a test and run axe against its DOM. Catches the basics (missing alt, wrong ARIA).
  • E2E with Playwright + @axe-core/playwright — scan a full rendered page in a real headless browser. Catches interaction-driven issues (live regions, focus order).
// unit: jest-axe
import { axe } from "jest-axe";
test("PricingCard is accessible", async () => {
  const { container } = render(<PricingCard tier="team" />);
  expect(await axe(container)).toHaveNoViolations();
});

// e2e: Playwright
import AxeBuilder from "@axe-core/playwright";
test("homepage has no serious violations", async ({ page }) => {
  await page.goto("/");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa"])
    .analyze();
  expect(results.violations.filter(v => v.impact === "serious" || v.impact === "critical")).toHaveLength(0);
});

CI pipeline

Unit + E2E tests live in your existing CI. For the end-user-facing "block merges on new accessibility regressions" outcome, add axle alongside:

# .github/workflows/accessibility.yml
name: Accessibility
on: pull_request

permissions:
  contents: read
  pull-requests: write

jobs:
  axle:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: asafamos/axle-action@v1
        with:
          install-command: npm ci
          build-command: npm run build
          start-command: npm start
          wait-on-port: "3000"
          fail-on: serious
          with-ai-fixes: "true"
          anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}