Developer Guides
Accessible React: Patterns That Actually Pass an Audit
Last updated
React did not invent accessibility bugs, but it did invent a few new ways to ship them. The class of issues that show up in audits of React apps is consistent and predictable: forms whose labels stop matching their inputs after the second instance of a component, single-page navigations that strand the keyboard user on a stale focus point, custom dropdowns that almost implement the W3C ARIA authoring pattern, and toast notifications that the screen reader never hears about. None of these are React's fault. All of them are easier to ship in React than in plain HTML, because the framework abstracts away exactly the things — IDs, focus, document structure, declarative state — that the assistive-technology stack depends on.
This page is the audit-ready playbook. It assumes you already know React, you ship to production, and you want patterns that survive contact with an actual accessibility test rather than rules of thumb that pass automated checks and fail users. The structure is opinionated: install eslint-plugin-jsx-a11y first because it catches the cheap stuff at the keystroke; reach for React Aria for any widget more complex than a button because nobody on your team has six weeks to re-implement the combobox keyboard model; lean on useId() for label-control associations because hardcoded IDs collide the moment a component is rendered twice on the page; and pay attention to focus on route changes because client-side navigation does not move it for you.
A small piece of good news for the React ecosystem: per WebAIM Million 2025, Next.js pages averaged 38.6 errors per home page — 24.2% below the all-sites average — outperforming most popular JavaScript frameworks. That is not a license to relax. It is evidence that modern React with sensible defaults gets you closer to the baseline than a typical CMS-driven site, which is a different statement than “your app is accessible.” And remember the ceiling on automated coverage: per Deque's Automated Accessibility Coverage Report, Deque reported automated tests identified 57.38% of issue instances by volume in its dataset — based on 2,000+ audits across 13,000+ pages and ~300K issues (a dataset-specific volume measure, not a clean automated-vs-manual split). Lint plus scanner plus primitive library gets you a defensible deterministic floor. Judgment-dependent items — keyboard flow, screen-reader experience, focus order, meaning — still require a manual pass.
ESLint jsx-a11y as your starting line
Before you write a single new component, install the linter. It is the cheapest possible feedback loop for the deterministic half of React accessibility — the patterns where the answer is yes or no, and an editor can tell you on every save instead of every PR review. The plugin ships a flat-config entry point that drops cleanly into a modern ESLint setup.
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
// …your other configs
jsxA11y.flatConfigs.recommended,
{
rules: {
// Bump a handful from warn to error in your own code.
'jsx-a11y/alt-text': 'error',
'jsx-a11y/anchor-has-content': 'error',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/no-noninteractive-element-interactions': 'error',
'jsx-a11y/click-events-have-key-events': 'error',
},
},
];The rules that pay for themselves most consistently: alt-text (every <img> gets an alt attribute, even if empty for decoration), anchor-has-content (no empty links from a missing prop), and label-has-associated-control (every <label> either wraps a control or has htmlFor). A handful of rules are advisory because they cannot prove a violation from static analysis alone — no-autofocus is the canonical example, since autofocus is sometimes the right call on a single-input page like a search modal. Read the warnings, do not silence them with a blanket disable comment.
Linter caveat: jsx-a11y inspects JSX statically. It does not know what your component renders at runtime. A custom <Image> wrapper that forgets to pass alt through will not trip the rule unless you configure the components option to tell it which JSX elements are img-like. Configure your wrappers. The legacy React docs — still a useful accessibility reference page even after the doc rewrite — and the MDN React accessibility module both cover the wrapper-config pattern in detail.
useId for label-control associations
The most common React-flavored accessibility bug is the same one every framework has: hardcoded IDs on form controls. It looks correct in isolation, passes every lint rule, and breaks the moment the component is rendered twice on a page. Two inputs with id="email" means at least one label points to the wrong control, and the page now ships duplicate-ID violations.
// BAD — hardcoded id, collides when rendered twice.
export function EmailField({ value, onChange }: Props) {
return (
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={value}
onChange={onChange}
/>
</div>
);
}Per the React reference for useId, the hook generates a stable, unique ID that is consistent between server and client renders. Use it for any element that needs an ID for accessibility wiring — labels, error descriptions, ARIA relationships — not as a key prop substitute.
import { useId } from 'react';
// GOOD — useId gives every instance its own stable id.
export function EmailField({ value, onChange, error }: Props) {
const id = useId();
const errorId = `${id}-error`;
return (
<div>
<label htmlFor={id}>Email address</label>
<input
id={id}
type="email"
value={value}
onChange={onChange}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error ? (
<p id={errorId} className="text-severity-error">
{error}
</p>
) : null}
</div>
);
}The same pattern handles aria-describedby for inline error messages, aria-labelledby for grouped controls, and dialog titles. Generate one ID per component instance and derive the rest by suffix — never call useId() in a loop. See our form labels guide for the manual side of label quality (visible, descriptive, persistent), which the hook will not fix for you.
Focus management after navigation
On a multi-page document the browser moves focus when the page changes. On a single-page app it does not, because nothing unloaded. The screen-reader user clicks a link, the URL updates, new content renders, and their focus is still on the link they activated — which is now in a stale position in the new document outline. Worse: there is often no audible cue that the navigation happened at all.
The minimum fix is to move focus to the new page's main heading or main landmark on every client-side route change. Programmatic focus requires a focusable target — set tabIndex={-1} on the heading so it can receive focus without entering the natural tab order.
'use client';
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
export function RouteAnnouncer({
children,
title,
}: {
children: React.ReactNode;
title: string;
}) {
const pathname = usePathname();
const headingRef = useRef<HTMLHeadingElement>(null);
// Skip the very first render — the browser already focused the page.
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
headingRef.current?.focus();
}, [pathname]);
return (
<main id="main">
<h1 ref={headingRef} tabIndex={-1} className="outline-none">
{title}
</h1>
{children}
</main>
);
}A more robust solution is to use a router-aware primitive. React Aria ships a RouterProvider that wires its components into your router so links, dialog close buttons, and other focusable widgets know about route transitions without each component re-implementing the dance. If you are already using a primitive library, prefer its integration over hand-rolling a hook on every page.
Modal dialogs without DIY focus trap
A correct accessible modal needs to do at least five things: trap focus inside the dialog while it is open, restore focus to the trigger on close, close on Escape, announce itself with a name and role, and prevent background interaction. The DIY div-with-role approach almost never lands all five. Per the W3C ARIA Authoring Practices Guide, the modal pattern is one of the more involved ones to get right, which is why both the native HTML element and the well-maintained primitive libraries are dramatically more reliable than a custom implementation.
// BAD — no focus trap, no focus restore, no aria, no Escape.
export function ConfirmModal({ open, onClose, children }: Props) {
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/40">
<div role="dialog" className="rounded bg-white p-6">
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}The simplest correct option in 2026 is the native <dialog> element, which now has stable cross-browser support. Calling showModal() gives you the focus trap, the Escape handler, and inert background for free.
import { useEffect, useId, useRef } from 'react';
export function ConfirmModal({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
onClose={onClose}
className="rounded-lg p-6 backdrop:bg-black/40"
>
<h2 id={titleId}>{title}</h2>
{children}
<form method="dialog">
<button type="submit">Close</button>
</form>
</dialog>
);
}For non-trivial modals — nested popovers, custom dismiss gestures, anything that needs portal flexibility — reach for React Aria's useDialog and useModalOverlay, or use Radix UI's Dialog. Both handle the keyboard model, focus restoration, and inertness for the background, and both expose enough customization that you rarely need to fall back to a custom implementation. See our accessible modals guide for the full pattern checklist including focus restoration after confirm flows and nested-dialog handling.
Live regions for async UI
Toast notifications, form-submission status, search-result counts, cart updates — anything that changes on the page without a full navigation needs an aria-live region so screen-reader users hear about it. The politeness level matters: use polite for status messages that should not interrupt (loading complete, item added to cart), and assertive for errors that need immediate attention (payment failed, form submission rejected).
The most common React-specific bug here is conditional rendering of the region itself. Assistive technologies subscribe to live regions when they first appear in the DOM — if you mount the region at the same moment you populate it, many AT implementations miss the change. The region must exist at initial render; only its content should change.
// BAD — the region mounts only when there is a message,
// so screen readers often miss the first announcement.
export function StatusRegion({ message }: { message?: string }) {
if (!message) return null;
return <div role="status" aria-live="polite">{message}</div>;
}// GOOD — region is always in the DOM; only the text changes.
export function StatusRegion({ message }: { message?: string }) {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message ?? ''}
</div>
);
}
// And for errors that should interrupt the current announcement:
export function ErrorRegion({ message }: { message?: string }) {
return (
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{message ?? ''}
</div>
);
}A few additional points worth knowing. role="status" implies aria-live="polite" and role="alert" implies aria-live="assertive" — specifying both is redundant but harmless and many teams keep both as documentation. aria-atomic="true" tells the screen reader to announce the entire updated content rather than only the diff, which matters when the region wraps structured text. Avoid using assertive for non-critical status — it interrupts whatever the user is listening to, and overuse trains them to ignore your app.
Accessible form validation in React
Forms are where accessibility quality is most visible to real users, and where the React abstractions trip teams up most often. The minimum bar: every field has a programmatic label, every invalid field carries aria-invalid="true", every inline error message is associated via aria-describedby, and on submit failure focus moves to either the first invalid field or an error summary at the top of the form. We cover this in depth in the form validation accessibility guide — what follows is the shape it takes in React.
'use client';
import { useId, useRef, useState } from 'react';
export function SignupForm() {
const emailId = useId();
const emailErrorId = `${emailId}-error`;
const summaryRef = useRef<HTMLDivElement>(null);
const [errors, setErrors] = useState<{ email?: string }>({});
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const next: { email?: string } = {};
const email = String(formData.get('email') ?? '');
if (!email.includes('@')) next.email = 'Enter a valid email address.';
setErrors(next);
if (Object.keys(next).length > 0) summaryRef.current?.focus();
}
return (
<form noValidate onSubmit={handleSubmit}>
{Object.keys(errors).length > 0 && (
<div
ref={summaryRef}
tabIndex={-1}
role="alert"
aria-labelledby="error-summary-heading"
>
<h2 id="error-summary-heading">There is a problem</h2>
<ul>
{errors.email && (
<li>
<a href={`#${emailId}`}>{errors.email}</a>
</li>
)}
</ul>
</div>
)}
<label htmlFor={emailId}>Email address</label>
<input
id={emailId}
name="email"
type="email"
aria-invalid={errors.email ? true : undefined}
aria-describedby={errors.email ? emailErrorId : undefined}
/>
{errors.email && <p id={emailErrorId}>{errors.email}</p>}
<button type="submit">Create account</button>
</form>
);
}Notes on the pattern: the summary container has tabIndex={-1} so we can move focus to it programmatically on submit failure; noValidate on the form lets us own the error UI rather than fight browser tooltips; each error link in the summary targets the field's ID so keyboard users can jump straight to the problem. Do not skip the aria-invalid attribute — many screen readers use it to announce “invalid” alongside the field name on focus.
Dropdown, combobox, tabs — use a primitive library
Buttons, inputs, links, dialogs, and forms have native HTML elements that do most of the work. Comboboxes, listboxes, tabs, menus, tree views, and date pickers do not. The keyboard interaction model alone — arrow keys, type-ahead, Home/End, page-up wrap-around, virtual focus — is several screens of spec per widget, and even the live APG examples have shipped bugs over the years. Re-implementing them is a senior-developer-week per widget and an audit liability forever.
Use a primitive library. The three serious options in 2026 are React Aria from Adobe (the most behaviorally complete and the only one explicitly designed around international keyboard models), Radix UI (most popular in the shadcn ecosystem; excellent default keyboard behavior), and Headless UI from the Tailwind team (smaller surface area, fine for simple needs). All three are dramatically better than rolling your own.
import { useComboBox, useFilter } from 'react-aria';
import { useComboBoxState } from 'react-stately';
import { Item } from 'react-stately';
export function CountryComboBox(props: ComboBoxProps) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const inputRef = useRef<HTMLInputElement>(null);
const listBoxRef = useRef<HTMLUListElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const {
buttonProps,
inputProps,
listBoxProps,
labelProps,
} = useComboBox(
{ ...props, inputRef, buttonRef, listBoxRef, popoverRef },
state,
);
// …render label + input + button + popover + listbox using the prop bags.
// React Aria handles arrow keys, type-ahead, virtual focus, ARIA wiring,
// and screen-reader announcements for you.
}The pitch is not that React Aria is the only correct choice. It is that any of these libraries gets you ARIA-spec-correct keyboard behavior for free, which is the bar you would otherwise be chasing for a sprint per widget. Pick one team-wide and stop reinventing comboboxes.
Common React a11y antipatterns
A condensed list of the patterns we see most often in real codebases, each of which surfaces in audits and is straightforward to fix once you know to look:
- Icon buttons without an accessible name. A
<button>that renders only an SVG and no text needsaria-label="Close"or a visually hidden span. The SVG itself should carryaria-hidden="true"so it does not duplicate the announcement. onClickon a<div>instead of a<button>. The div is not keyboard focusable, has no role, and skipping the button means re-implementing the Enter/Space activation contract. Use a real button (or a link if it navigates).- Conditionally rendering an
aria-liveregion. Defeats the purpose — AT does not subscribe to a region that did not exist at the moment the change happened. Mount it once; mutate its text. - Re-using the same
idacross multiple instances of a component. Causes duplicate-ID violations and broken label associations the moment the component is rendered twice. UseuseId(). - Trapping focus inside a non-modal element. A focus trap is correct for a modal dialog. It is wrong for a disclosure, a tooltip, a popover, or anything the user is expected to be able to tab past. Match the trap to the modality.
- Custom
tabIndexvalues greater than zero. Re-orders the document tab sequence in ways that almost never survive a maintenance pass. Stick to0(focusable, natural order) and-1(programmatically focusable, not in tab order). - Skipped heading levels. Component-driven UIs make it easy to drop an
<h3>inside a layout that does not have an<h2>. Heading rank is a document property, not a component property — have your section components accept a level prop or use<hN>consistently per route. See the landmarks guide for the document-structure half.
Frequently asked questions
- Does jsx-a11y catch most React accessibility issues?
- No. It catches the static, JSX-visible portion — missing alt attributes, label-control pairing, anchors without content, clicks on non-interactive elements. That is meaningful coverage at zero runtime cost, but it does not run your app, so it cannot see focus management, live-region timing, or whether your custom widget implements the right keyboard model. Treat the linter as your floor and pair it with a scanner plus a manual pass.
- Should I use React Aria or Radix UI?
- Both are excellent. React Aria from Adobe is the most behaviorally complete and the only one explicitly designed around international keyboard and text-input models — pick it if you need date pickers, locale-aware comboboxes, or rich-text inputs. Radix UI is more popular in the shadcn ecosystem, has a smaller API surface, and is easier to skin with Tailwind. Either gets you ARIA-spec-correct keyboard behavior for free. The wrong answer is rolling your own.
- Why does Next.js score better on WebAIM Million than other frameworks?
- Per WebAIM Million 2025, Next.js pages averaged 38.6 errors per home page — 24.2% below the all-sites average — outperforming most popular JavaScript frameworks. The likely contributors are server rendering by default (which produces accessible HTML even when client hydration fails), the strong ecosystem nudge toward jsx-a11y in starter templates, and the fact that Next.js teams skew newer and adopt modern primitive libraries more readily than the long tail of WordPress and Shopify sites. The benchmark measures home pages and uses one engine, so treat the comparison as directional, not definitive.
- Can I use dangerouslySetInnerHTML and still be accessible?
- Yes, if the HTML you inject is itself accessible. The hazard is not the API; it is that you have stopped letting React keep your output consistent. Sanitize, validate that the inserted markup has correct structure (headings, lists, links with content), and if it comes from user input or a CMS run it through an accessibility check on the way in. The bigger risk is usually XSS, which is the original reason for the warning prefix.
- Will SweepHound flag my SPA’s missing route-change focus?
- Partially. Our scanner sees the rendered DOM at each route, so it catches missing landmarks, missing headings, and structural issues on every page in your sitemap. Pure runtime behaviors — focus moving correctly after a client-side navigation, a live region firing at the right moment — are not directly visible to an automated tool and surface in the manual review prompts we ship with every scan. The Growth and Agency tiers support authenticated scanning so the routes behind your login wall are in scope too.
How SweepHound supports React apps
SweepHound is a runtime scanner, not a build-time linter. We render every route in a real browser and inspect the resulting DOM, which means client-rendered React content is in scope just like static HTML — if a component shipped to production renders an unlabeled input, we see the unlabeled input. We dedupe findings by element so a single button does not generate four tickets, and we group at the rule level so fixing one component closes many instances at once.
Authenticated scanning on the Growth and Agency tiers covers the routes behind your login wall — the dashboards, account pages, and checkout flows where most product accessibility work actually lives. Every scan ends with a manual-review checklist covering the things automated tooling cannot evaluate (keyboard flow, screen-reader experience, focus quality), so you have a finite list of human checks rather than a vague gesture at “and now do some manual testing.” The WCAG 2.2 checklist and the manual accessibility checklist map cleanly onto that flow.
If you want to see what an honest scan of your React app turns up, start a free scan — you get the full sitemap crawl, grouped findings, and the manual-review prompts on the free tier. Paid tiers add authenticated scanning, scheduled re-scans, and the public statement generator; see pricing for the breakdown. For the framework-specific complement to this page, our accessible Next.js guide covers App Router, server components, and metadata patterns. For the deeper crawl into what no scanner can see, the what scanners miss piece is the honest companion to this page.
Convinced enough to run it? Sign up and you can scan a site in under a minute.
Sources
- React Aria (Adobe Spectrum) — Headless primitive library with the most behaviorally complete accessibility support among React UI toolkits. Our recommended starting point for any non-trivial widget.
- W3C ARIA Authoring Practices Guide — Reference patterns for modals, comboboxes, tabs, menus, and other composite widgets. Read before re-implementing any of these from scratch.
- React reference: useId — Stable, unique ID generation that is consistent between server and client renders. Use for label-control associations, aria-describedby, and dialog titles.
- React legacy docs: Accessibility — Historical reference still useful for the wrapper-component pattern and the broader rationale behind jsx-a11y.
- MDN: React accessibility module — MDN learning module that walks through the same patterns end to end with worked examples.
- eslint-plugin-jsx-a11y — ESLint plugin that catches the static, JSX-visible portion of accessibility issues at the keystroke. Install before writing new components.
- WebAIM Million 2025 — Next.js pages averaged 38.6 errors per home page — 24.2% below the all-sites average — outperforming most popular JavaScript frameworks.
- Deque, Automated Accessibility Coverage Report — Deque reported automated tests identified 57.38% of issue instances by volume in its dataset (2,000+ audits across 13,000+ pages, ~300K issues).