React Accessibility: The Complete WCAG 2.2 Guide
React is accessible when you build it that way. This guide covers the patterns that actually trip React apps up — semantic JSX, focus on route changes, accessible modals, ARIA in JSX, live announcements, and forms with useId — with copy-ready components and a testing workflow that keeps them accessible.
Why React Accessibility Is Different
React does not make a page inaccessible on its own — it renders whatever elements you write. If you reach for a real <button>, <a>, <nav>, and <label>, you inherit the keyboard behavior, focus handling, and screen reader semantics those elements already ship with. The trouble is that React makes it just as easy to render a <div onClick> that looks like a button but is invisible to assistive technology.
Three things about the React model create accessibility work you would not have on a static site. First, client-side routing swaps content without a full page load, so focus and screen reader context are never reset unless you do it. Second, dynamic rendering updates the DOM constantly — results, toasts, validation — and none of it is announced unless you use a live region. Third, component abstraction hides markup behind reusable components, so a single wrong choice (a div for a button, a missing label) is repeated everywhere it is used. Get these three right and most of React accessibility falls into place.
The WCAG 2.2 Criteria React Apps Break Most
| Criterion | Level | What it requires in React |
|---|---|---|
| 1.3.1 Info & Relationships | A | Use semantic JSX; wire labels and errors with useId. |
| 2.1.1 Keyboard | A | Interactive elements must be real buttons/links, not divs. |
| 2.1.2 No Keyboard Trap | A | Modals trap focus deliberately and release it on close. |
| 2.4.3 Focus Order | A | Move focus on route change and on open/close of overlays. |
| 2.4.7 Focus Visible | AA | Never remove focus outlines without a visible replacement. |
| 3.3.1 Error Identification | A | Tie validation messages to fields with aria-describedby. |
| 4.1.2 Name, Role, Value | A | Custom components expose an accessible name, role, and state. |
| 4.1.3 Status Messages | AA | Announce async updates through an aria-live region. |
For the full list, see the WCAG 2.2 Level AA requirements and the interactive WCAG 2.2 checklist.
1. Write Semantic JSX First
The single highest-impact rule in React accessibility: render the element that already does the job. A <button> is focusable, fires on Enter and Space, and announces its role. A <div onClick> does none of that until you add a role, tabIndex, and keyboard handlers by hand — and get all three exactly right.
// Inaccessible: not focusable, no keyboard, no role
<div className="btn" onClick={handleSave}>Save</div>
// Accessible: keyboard + role + focus for free
<button type="button" onClick={handleSave}>Save</button>
// Navigation is a list of links inside <nav>
<nav aria-label="Primary">
<ul>
<li><Link href="/pricing">Pricing</Link></li>
<li><Link href="/guides">Guides</Link></li>
</ul>
</nav>Use one <h1> per page and keep headings in order (h1 → h2 → h3) so screen reader users can navigate by heading. Wrap the primary content in <main>, and reach for <button> for actions and <a>/<Link> for navigation — the difference matters to assistive tech even when they look identical.
2. Manage Focus on Route Changes
When a client-side route changes, the browser does not reset focus the way a full page load would. Keyboard and screen reader users are left on whatever link they clicked, now pointing at content that no longer exists. Move focus to the top of the new view — usually its heading.
// A route-aware heading that receives focus after navigation
import { useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
export function PageHeading({ children }) {
const ref = useRef(null)
const pathname = usePathname()
useEffect(() => {
ref.current?.focus()
}, [pathname])
return (
<h1 ref={ref} tabIndex={-1} className="outline-none">
{children}
</h1>
)
}tabIndex={-1} lets the heading receive programmatic focus without adding it to the Tab order. Next.js App Router also ships a built-in route announcer that reads the new document <title> on navigation, so keeping page titles unique and descriptive is part of the same job. This satisfies 2.4.3 Focus Order. Provide a skip link to #main as well.
3. Accessible Modals & Focus Trapping
A dialog is the classic React focus challenge. When it opens, focus must move into it; while open, focus must stay trapped inside; on close, focus must return to the control that opened it. The native <dialog> element handles most of this, and its showModal() method traps focus and supports Escape for you.
import { useEffect, useRef } from "react"
export function ConfirmDialog({ open, onClose, children }) {
const dialogRef = useRef(null)
useEffect(() => {
const node = dialogRef.current
if (!node) return
if (open) node.showModal() // traps focus + enables Escape
else node.close()
}, [open])
return (
<dialog
ref={dialogRef}
aria-labelledby="dialog-title"
onClose={onClose}
className="rounded-lg p-6 backdrop:bg-black/50"
>
<h2 id="dialog-title">Delete this project?</h2>
{children}
<button type="button" onClick={onClose}>Cancel</button>
</dialog>
)
}If you build a custom overlay instead of using <dialog>, you must add role="dialog", aria-modal="true", an accessible name via aria-labelledby, a focus trap, Escape-to-close, and focus restoration yourself — which is exactly why headless libraries like Radix UI, React Aria, and Headless UI exist. See the accessible modal pattern for the full interaction spec.
4. Announce Dynamic Content with Live Regions
React updates the DOM silently. When search results load, a toast appears, or a form saves, a sighted user sees it instantly — a screen reader user hears nothing unless the change happens inside an aria-live region. Render a persistent live region and update its text.
function SearchResults({ status, results }) {
return (
<div>
{/* Polite: announced when the user is idle, never interrupts */}
<p aria-live="polite" className="sr-only">
{status === "loading"
? "Searching…"
: results.length + " results found"}
</p>
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</div>
)
}Use aria-live="polite" for status updates and aria-live="assertive" only for urgent, interrupting messages like a session-timeout warning. The region must exist in the DOM before its text changes, so render it empty rather than mounting it on demand. This satisfies 4.1.3 Status Messages.
5. Accessible Forms with useId
In a reusable input component you cannot hardcode an id— render it twice and the ids collide, breaking every label and error association. React's useId hook generates a stable, hydration-safe id. Derive the field and error ids from it and wire them with htmlFor, aria-describedby, and aria-invalid.
import { useId } from "react"
export function TextField({ label, error, ...props }) {
const id = useId()
const errorId = id + "-error"
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p id={errorId} className="text-red-600">
{error}
</p>
)}
</div>
)
}Note the JSX specifics: use htmlFor (not for) and className (not class), but keep aria-* attributes hyphenated. Only set aria-invalid and aria-describedby when there is actually an error, so assistive tech is not told about a message that is not shown. For the full treatment of labels, grouping, validation, and error summaries, see the accessible forms guide.
6. ARIA in JSX & Custom Widgets
When you genuinely need a custom widget — a disclosure, a tab set, a combobox — ARIA describes its state to assistive technology. Follow the ARIA Authoring Practices patterns exactly, and mirror the ARIA state in your React state.
function Disclosure({ label, children }) {
const [open, setOpen] = useState(false)
const panelId = useId()
return (
<>
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
>
{label}
</button>
<div id={panelId} hidden={!open}>
{children}
</div>
</>
)
}Because the toggle is a real <button>, keyboard support and focus come free — you only add aria-expanded and aria-controls to describe the relationship. For widgets that need arrow-key navigation (tabs, menus, listboxes), implement a roving tabIndex and the documented key bindings. Our ARIA roles & attributes reference lists the exact roles and states for each pattern.
Keyboard Rules for React
- Interactive = real
<button>or<a>, never a div. - Move focus with refs on open, close, and route change.
- Trap focus in modals; restore it to the trigger on close.
- Keep a visible focus outline (2.4.7).
- Roving
tabIndexfor arrow-key widgets.
See the keyboard accessibility guide.
Screen Reader Rules
- Announce route changes and async updates via live regions.
- Every control has an accessible name (label or
aria-label). - Icon-only buttons need
aria-label; decorative icons getaria-hidden. - Custom widgets expose role, name, and current value.
- Images use meaningful
alt, oralt=""if decorative.
Test with real AT — the screen reader testing guide.
7. Testing & Tooling
Automated checks catch a meaningful share of issues and stop regressions — but they find roughly a third to a half of WCAG problems, so they supplement rather than replace manual testing. Layer three tools into your workflow:
// 1. Lint as you type — jsx-a11y ships with Next.js's ESLint config
// Flags <img> without alt, onClick on non-interactive elements, etc.
// 2. Component tests: React Testing Library + jest-axe
import { render } from "@testing-library/react"
import { axe } from "jest-axe"
test("TextField has no axe violations", async () => {
const { container } = render(
<TextField label="Email" error="Enter a valid email" />
)
expect(await axe(container)).toHaveNoViolations()
})
// 3. End-to-end: axe-core in Playwright against real pages
import AxeBuilder from "@axe-core/playwright"
test("home page is accessible", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
})React Testing Library nudges you toward accessible queries — getByRole and getByLabelText only pass when the accessibility tree is correct, so writing tests this way surfaces missing names early. Finish every feature with a manual keyboard pass and a screen reader pass. Read our comparison of automated vs manual testing to see where each fits.
Common React Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| <div onClick={...}> used as a button. | Not focusable, not keyboard-operable, no role announced (WCAG 2.1.1, 4.1.2). | Use a real <button>. eslint-plugin-jsx-a11y flags this automatically. |
| Route changes that never move focus. | Keyboard and screen reader users are stranded on the old page (2.4.3). | Focus the new page's <h1> or <main> after navigation. |
| Dynamic results/toasts updated silently. | Screen readers never hear the change (4.1.3 Status Messages). | Render updates inside an aria-live region. |
| Hardcoded id on a reusable labeled input. | Duplicate ids break label/error associations when reused. | Generate ids with the useId hook. |
| autoFocus and index-based key spread everywhere. | Unexpected focus jumps and re-mount focus loss disorient users. | Move focus intentionally with refs at the right moment. |
| Modal that doesn't trap focus or restore it on close. | Focus escapes behind the overlay; users get lost (2.4.3, 2.1.2). | Trap focus in the dialog, return it to the trigger on close. |
React Accessibility Checklist
- Semantic elements. Every clickable thing is a
<button>or<a>; headings are ordered; one<h1>per view. - Focus on navigation. Route changes move focus to the new heading or main region.
- Overlays. Modals trap focus, close on Escape, and restore focus to the trigger.
- Live regions. Async results, toasts, and errors are announced.
- Forms. Labels tied with
useId; errors linked witharia-describedbyandaria-invalid. - Names. Icon-only buttons have
aria-label; decorative images usealt="". - Automated + manual. jsx-a11y + jest-axe in CI, plus a keyboard and screen reader pass.
Scan the deployed build with our URL accessibility auditor and work through the full WCAG 2.2 checklist.
Audit Your React App in Seconds
Run any deployed React or Next.js page through our free axe-core-powered auditor to catch missing names, unlabeled controls, and contrast failures — then work through the manual checks above.
Frequently Asked Questions
Is React accessible by default?▾
React itself is neutral — it renders whatever elements you write. If you write semantic HTML (button, a, nav, label, input, h1–h6), your app inherits the accessibility those elements already have. Problems come from React patterns, not React itself: div-and-span soup with onClick handlers, single-page navigation that never moves focus, dynamic content that screen readers never hear about, and custom widgets that reimplement native controls without keyboard support. This guide covers each of those. In short, React can be fully WCAG 2.2 AA accessible, but accessibility is something you build in, not something you get for free.
How do I manage focus when the route changes in a React SPA?▾
In a traditional multi-page site, each navigation loads a new document and the browser resets focus to the top. A React single-page app swaps content without a full load, so focus stays wherever it was — often on a link inside the old page. That silently strands keyboard and screen reader users. The fix is to move focus to a sensible target after each route change: focus the new page's <h1> (with tabIndex={-1}) or a top-level main element, and announce the new page. Next.js App Router ships a built-in route announcer that reads the new document title on client navigation, but you still typically want to manage focus placement yourself for the clearest experience. This satisfies WCAG 2.4.3 Focus Order.
How do I write ARIA attributes in JSX?▾
ARIA attributes keep their hyphens in JSX — you write aria-label, aria-describedby, aria-expanded, and role exactly as in HTML, unlike other DOM props such as className and htmlFor. Boolean ARIA states should be real booleans or strings depending on the attribute: aria-expanded={isOpen} works because React serialises the boolean to "true"/"false". Always prefer a native element with built-in semantics over adding a role; the first rule of ARIA is don't use ARIA if a native element will do. Our ARIA reference documents every role and state with copy-ready examples.
What is useId and why does it matter for accessibility?▾
React's useId hook (React 18+) generates a stable, unique id that is consistent between server and client rendering. It is the correct way to wire a label to an input, or an input to its error message via aria-describedby, in a reusable component — because hardcoding an id breaks when the component is rendered more than once on a page, and generating a random id causes server/client hydration mismatches. Call useId once and derive related ids from it (for example inputId and errorId) so a single component instance keeps its associations intact.
Do I need ARIA if I use a component library?▾
It depends on the library. Headless libraries built for accessibility — React Aria, Radix UI, Headless UI, Reach UI — implement the ARIA Authoring Practices patterns, keyboard interaction, and focus management for you, so you mostly supply labels and content. Purely visual libraries may render inaccessible markup that you still have to fix. Either way, never assume: test the real rendered output with a keyboard and a screen reader, because even good libraries can be misconfigured (an unlabeled icon button, a dialog without an accessible name).
How do I test a React app for accessibility?▾
Use three layers. First, lint: eslint-plugin-jsx-a11y (bundled with Next.js's default ESLint config) catches many issues as you type, such as a missing alt or an onClick on a non-interactive element. Second, unit and integration tests: render components with React Testing Library — which encourages accessible queries like getByRole and getByLabelText — and assert on them with jest-axe to catch violations automatically. Third, end-to-end: run axe-core in Playwright or Cypress against real pages. None of these replace manual keyboard and screen reader testing, which is the only way to confirm the experience actually works.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences