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 }}