React gives you total control over the DOM, which means React accessibility is entirely your responsibility. The library renders exactly the JSX you write, so a page assembled from clickable <div> elements ships to screen readers as an unlabeled, unnavigable blob, and no amount of clever state management fixes that.
The good news: the same component model that makes it easy to ship inaccessible markup also makes it easy to ship accessible markup once, in a shared component, and reuse it everywhere. This guide covers semantic JSX, focus management on route change, accessible components, the single-page-app pitfalls that automated tools miss, and how to test for them. The baseline throughout is WCAG 2.2 Level A and AA, the standard referenced by EN 301 549 and the European Accessibility Act.
Start with semantic JSX, not div soup
The single highest-leverage habit in React accessibility is reaching for the correct HTML element before reaching for ARIA. A <button> is focusable, fires on Enter and Space, and announces its role for free. A <div onClick> does none of that, and patching it back to parity takes role="button", tabIndex={0}, and a keydown handler that most teams forget.
JSX makes the right choice as cheap as the wrong one. Use <nav>, <main>, <header>, and <footer> for landmarks so screen-reader users can jump between regions. Use one <h1> per route and keep heading levels sequential, because assistive tech builds a navigable outline from them. Use <ul>/<li> for lists and <button> for actions, reserving <a> for things that navigate to a URL.
ARIA is a patch, not a primitive
The first rule of ARIA is to not use ARIA when a native element exists. Reach for it only to fill genuine gaps: aria-label on an icon-only button, aria-expanded on a disclosure toggle, aria-live on a region that updates without a navigation. Wrong or redundant ARIA is worse than none, since it overrides the accessibility tree. Our ARIA best practices guide covers the common traps.
Manage focus on every route change
This is the defining accessibility failure of single-page apps, and React Router, TanStack Router, and Next.js all share it. In a traditional multi-page site, clicking a link loads a new document and the browser moves focus to the top. In a client-side router, the URL and DOM swap but focus stays put, often on a link that no longer exists. Keyboard users are stranded and screen-reader users hear nothing announced.
The fix is to take over what the browser used to do. After each navigation, move focus to a sensible target and announce the change. A common, robust pattern is to render a visually styled, programmatically focusable heading at the top of each route and focus it in an effect when the path changes:
useEffect(() => { headingRef.current?.focus(); }, [pathname]); with the heading marked tabIndex={-1} so it can receive focus without entering the tab order.
Pair that with a skip link as the first focusable element in the document (<a href="#main">Skip to content</a>) and a route-change announcer, a visually hidden element with aria-live="polite" whose text you update to the new page title. Keyboard access is a WCAG 2.1.1 requirement at Level A; our keyboard accessibility guide goes deeper on order and visible focus.
Build accessible components once and reuse them
Components are where React accessibility scales. Get a primitive right one time and every instance inherits it. The flip side: a single broken <Button> or <Input> multiplies across the whole app.
- Forms: associate every input with a
<label htmlFor>(placeholder text is not a label), wire validation errors witharia-describedby, and setaria-invalidon failed fields. See accessible forms for the full pattern. - Modals and dialogs: prefer the native
<dialog>withshowModal(), which traps focus, supports Escape, and exposes a backdrop. If you hand-roll one, trap Tab inside it, restore focus to the trigger on close, and mark the dialogaria-modal="true". - Icon buttons: every control needs an accessible name. Give icon-only buttons an
aria-labeland mark decorative SVGsaria-hidden="true". - Custom widgets: tabs, comboboxes, and menus need full keyboard support (arrow keys, Home/End, Escape) and the right roles. Headless libraries like Radix or React Aria implement the WAI-ARIA Authoring Practices so you do not have to.
Mind WCAG 2.2's newer criteria while you build: 2.5.8 Target Size (Minimum, AA) wants interactive targets of at least 24 by 24 CSS pixels, 2.4.11 Focus Not Obscured (Minimum, AA) means sticky headers must not hide the focused element, and 3.3.8 Accessible Authentication (Minimum, AA) discourages cognitive-test login steps. Text contrast must hit 4.5:1, or 3:1 for large text (at least 18pt, or 14pt bold) and UI components, per color contrast requirements.
Common SPA pitfalls automated tools miss
- Dynamic content with no announcement: results that load after a fetch, toast notifications, and inline validation are silent unless they live in an
aria-liveregion or receive focus. - Conditional rendering breaking focus: when you unmount the element that had focus (closing a menu, removing a row), focus falls back to
<body>and the user loses their place. Move it somewhere deliberate first. - Hydration and SSR mismatches: a server-rendered tree that differs from the client can momentarily strip ARIA state. Generate stable ids with React's
useIdrather than random values. autoFocusoveruse: auto-focusing inputs on mount can yank screen-reader users past page context. Use focus management on intent, not on render.- Animations ignoring reduced motion: route transitions and parallax should respect
prefers-reduced-motionto avoid triggering vestibular discomfort.
None of these reliably trip a linter, because they are runtime and interaction behaviors, not static markup. That is exactly why testing matters.
Test React accessibility in layers
No single tool covers WCAG, so stack complementary checks and run them where they are cheapest to fix.
- Static linting: eslint-plugin-jsx-a11y flags missing alt, invalid ARIA, and unlabeled controls as you type. It is fast and free but shallow.
- Component tests: jest-axe or @axe-core/react runs axe inside your unit or Storybook tests, catching regressions per component before they ship.
- Keyboard pass: tab through every flow with no mouse. You should always see a visible focus indicator, never get trapped, and reach every control in a logical order.
- Screen-reader pass: test with VoiceOver, NVDA, or JAWS on your critical paths. This is the only way to confirm names, roles, and live announcements actually work. See screen reader testing.
Automated tools catch only a fraction of WCAG issues, mostly static rule violations, so manual testing is not optional, a balance our automated vs manual accessibility testing guide breaks down. For a fast first look at a deployed page, run it through AccessScan's free checker at the scanner to surface contrast, labeling, and structural problems, then verify the interactive behavior by hand. Work through the accessibility checklist to confirm you have covered the WCAG 2.2 AA baseline before you ship.