Fixing Accessibility in React and Next.js
React and Next.js do not make a site inaccessible by themselves, but they make certain mistakes very easy: a clickable div in a shared component that repeats on every page, a route change that updates the screen while a screen reader hears nothing, focus lost when a component unmounts, a hand-rolled modal that lets Tab wander behind it.
Because components are reused, one fault becomes hundreds of findings, and one fix clears them all. This guide covers the problems specific to component-based apps and how to catch them in the build.
How React apps fail differently from ordinary sites
A traditional website loads a fresh HTML document for each page. The browser resets focus to the top, the screen reader announces the new title, and everything in the markup came from one template. A React application breaks those assumptions in three ways.
- Client-side navigation. Clicking a link swaps part of the page with JavaScript. The browser does not load a new document, so it does not reset focus or announce anything. A screen reader user may hear nothing at all, and focus may stay on a link that no longer exists.
- Components that appear and disappear. When a toast, dropdown or basket row is removed
from the tree and it held focus, focus falls back to the
<body>. The next Tab press starts again from the top of the page. - Everything is reusable, including mistakes. A
CardorIconButtoncomponent written once with a click handler on a<div>puts that fault on every page that uses it.
The general WCAG topics still apply and have their own guides: alt text, form labels, heading structure, colour contrast, focus indicators and ARIA labels. This guide covers what is different when the markup is generated by components.
Tracing a finding back to its component
The Accessibility Audit reports findings against the rendered HTML, which is what browsers and assistive technology receive. In a React app, the job is to map each failing element back to the component that produced it.
- Group findings by pattern. If the same failure appears on every product card or in every page header, it is one component, not many pages.
- Use React Developer Tools. Select the failing element in the browser's element inspector, then switch to the Components tab of the React Developer Tools extension, which shows which component rendered it and with which props.
- Check whether it is yours. If the element comes from a UI library, check the library version and its accessibility notes before patching around it. Many issues are fixed in newer releases or caused by passing the wrong props.
- Check server-rendered output too. In Next.js, view the page source to see the HTML
sent before JavaScript runs. Missing
langattributes, titles and alt text are often visible there.
The fix, step by step
Step 1
Add accessibility linting to the build
Install eslint-plugin-jsx-a11y and enable its recommended rule set in your
ESLint configuration. It flags click handlers on non-interactive elements, images without alt text, anchors
without a valid href, form controls without labels and invalid ARIA attributes while you
type. The Next.js ESLint configuration includes only a few of these rules, so add the recommended set
explicitly. Run ESLint in continuous integration and treat accessibility errors as build failures so
new faults cannot be merged.
Step 2
Swap clickable divs for native elements in shared components
Search the component folder for onClick on div,
span and li. Anything that navigates should render a link, which in Next.js
means the Link component producing a real <a href>, never a div that
calls router.push. Anything that acts on the page should render <button type="button">.
Fix the base components first, such as Button, IconButton and Card,
and every screen built from them improves at once.
Step 3
Connect labels and descriptions with useId
Hard-coded id values break when a component appears twice on a page, which
silently disconnects labels, hints and error messages from their fields. Generate ids with the useId
hook, available since React 18, and use them for htmlFor, aria-describedby
and aria-labelledby. Because useId produces the same value on the server and
in the browser, it also avoids hydration mismatches that random ids cause in server-rendered Next.js
pages.
Step 4
Move focus and announce route changes
Give
every page a unique document title, because screen readers and the Next.js route announcer both rely
on it. After a client-side navigation, move focus to the main heading of the new page, which has tabindex="-1"
so it can receive focus from script. Skip this on the first page load. In plain React Router apps, where
nothing is announced by default, this focus move is what tells screen reader users the page has changed.
Step 5
Manage focus when components mount and unmount
Whenever an action removes the element that has focus, decide where focus should go next and move it there with a ref. Deleting a basket row can focus the next row or the basket heading; closing a panel returns focus to its toggle; submitting a form that is replaced by a thank-you message can focus that message. Render status messages into a live region that already exists in the page, rather than mounting the region and its text together.
Step 6
Use a tested library for dialogs, menus and tabs
Modals, dropdown menus, comboboxes, tabs and date pickers need precise keyboard
behaviour and ARIA states that are hard to get right by hand. Use the native <dialog>
element for simple modals, and for complex widgets use an established headless library whose documentation
describes its keyboard support. Keep it updated, pass the labelling props it asks for, and do not override
its roles or key handlers in your own wrapper components.
Step 7
Test rendered components with axe and role queries
Write component tests with Testing Library that find elements by role and accessible
name, such as getByRole('button', { name: 'Add to basket' }), so a test fails if a button
loses its name or becomes a div. Add an axe-core check to those tests with a helper such as jest-axe,
and run axe against key pages in end-to-end tests. Automated checks catch a share of issues, so still
test key journeys by hand with a keyboard and a screen reader.
Code you can adapt
A button component that cannot be misused
export function IconButton({ label, icon: Icon, ...props }) {
return (
<button type="button" aria-label={label} {...props}>
<Icon aria-hidden="true" focusable="false" />
</button>
);
}
// usage
<IconButton label="Remove from basket" icon={TrashIcon} onClick={remove} />
Making label a required prop (in TypeScript, a non-optional string) means nobody can
render an icon button without a name.
A field with connected label, hint and error
import { useId } from 'react';
export function TextField({ label, hint, error, ...props }) {
const id = useId();
const hintId = hint ? id + '-hint' : undefined;
const errorId = error ? id + '-error' : undefined;
return (
<div className="field">
<label htmlFor={id}>{label}</label>
{hint && <p id={hintId}>{hint}</p>}
<input
id={id}
aria-describedby={[hintId, errorId].filter(Boolean).join(' ') || undefined}
aria-invalid={error ? true : undefined}
{...props}
/>
{error && <p id={errorId}>{error}</p>}
</div>
);
}
Focusing the page heading after navigation (Next.js App Router)
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function PageHeading({ children }) {
const ref = useRef(null);
const pathname = usePathname();
const first = useRef(true);
useEffect(() => {
if (first.current) { first.current = false; return; }
ref.current?.focus();
}, [pathname]);
return <h1 ref={ref} tabIndex={-1}>{children}</h1>;
}
Use this component for the main heading in each page. In React Router the same idea works with the
useLocation hook. Give the heading a focus style that is visible but not jarring, since
it is not an interactive element.
A live region that is always present
export function StatusMessage({ message }) {
return (
<div role="status" aria-live="polite" className="status">
{message}
</div>
);
}
Render StatusMessage once near the top of the layout and change its message
prop. Screen readers announce changes to a region they are already watching; a region that is mounted
at the same moment as its text is often missed.
Next.js specifics
| Area | What to check |
|---|---|
| Page language | Set lang on the <html> element in the root
layout (App Router) or in the custom Document (Pages Router), for example en-GB. Multilingual
sites should set it per locale. |
| Page titles | Export metadata or generateMetadata from each route
so every page has a unique title. The built-in route announcer reads the title first when a client-side
navigation happens. |
| Links | Use the Link component, which renders a real anchor. Do not wrap it
in a clickable div or nest a button inside it. |
| Images | The Image component expects an alt prop. Pass meaningful
text, or an empty string for decorative images. |
| Loading states | loading.js skeletons and Suspense fallbacks should include
a text alternative such as "Loading products", announced through a status region. |
| Server and client components | Interactive widgets must be client components. A server component that renders a styled div for a control that only becomes a button after hydration leaves it unusable during load. |
The route announcer tells screen reader users that a new page loaded, but it does not move keyboard focus. Test whether your layout keeps focus somewhere sensible after navigation; if focus is left on a link in a menu that has now closed, add the heading focus pattern above.
Choosing component libraries
A good headless or styled component library can save weeks of work, because it ships widgets that already follow the W3C ARIA Authoring Practices keyboard patterns. Libraries widely used for this include React Aria from Adobe, Radix UI, Headless UI and Ariakit; styled systems such as MUI build on similar patterns. When comparing them, look for:
- Documentation that states the keyboard interactions for each component.
- Focus trapping and focus return built into dialogs and popovers.
- Props for accessible names, and warnings in development when they are missing.
- Active maintenance and a public issue tracker where accessibility bugs are fixed.
Testing strategy that fits a component codebase
- While coding: the ESLint jsx-a11y rules and a browser extension based on axe-core
highlight problems in the page you are working on. The older
@axe-core/reactconsole helper does not support React 18 and later, so do not rely on it for current apps. - In unit and component tests: query by role and accessible name with Testing Library,
and assert no axe violations on rendered components with
jest-axeor an equivalent for Vitest. - In end-to-end tests: run axe on key pages and after key interactions, for example
with
@axe-core/playwright, and script keyboard journeys such as opening the menu and completing checkout with key presses only. - Before release: run the Accessibility Checker on each page type, and do a short manual pass with a keyboard and with NVDA or VoiceOver.
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import { ProductCard } from './ProductCard';
test('product card is accessible', async () => {
const { container } = render(<ProductCard name="Oak side table" price="ยฃ120" />);
expect(screen.getByRole('link', { name: 'Oak side table' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add oak side table to basket/i })).toBeInTheDocument();
expect(await axe(container)).toHaveNoViolations();
});
This test needs the toHaveNoViolations matcher from jest-axe and the DOM
matchers from @testing-library/jest-dom registered in your test setup file.
Edge cases
Portals and stacking
Modals rendered with createPortal sit at the end of the body in the DOM, far from the
button that opened them. Without focus management, keyboard users must Tab through the whole page to
reach them. Move focus in on open, contain it, and return it on close, or use <dialog>
with showModal(), which handles the containment for you.
The inert attribute
To stop focus reaching the page behind an open drawer, add inert to the main content
wrapper. React 19 accepts it as a normal boolean prop; earlier versions need it passed as an empty string
when true and left off when false.
Fragments and invalid structure
Components that return a wrapping <div> inside a <ul> or <table>
break list and table semantics, so screen readers stop announcing item counts or column headers. Return
a fragment, or make the component render the <li> or <tr> itself.
Animations
Respect the reduced motion setting with the prefers-reduced-motion media query, and check
that animation libraries you use do the same for page transitions and parallax effects.
1. Is React bad for accessibility?
No. React renders ordinary HTML, and a React app can be every bit as accessible as a static site. The risks come from habits the framework makes easy, such as click handlers on divs, client-side navigation that announces nothing and components that remove focused elements. Using native elements, managing focus deliberately and adding lint rules and tests to the build addresses most of them. Many accessible design systems used by large organisations are built with React.
2. Does Next.js announce page changes to screen readers?
Yes. Next.js includes a route announcer that, after a client-side navigation, announces the new page to screen readers. It looks for the document title first, then the first main heading, then the address. That is why unique page titles matter. It does not move keyboard focus, though, so keyboard users can be left on a link in a menu that has closed. Test navigation with the keyboard and add focus handling where needed.
3. Where should focus go after a route change in a single-page app?
The most common and well-tested choice is the main heading of the new page, given a tabindex of minus one so script can focus it. A screen reader then reads the heading, and the next Tab press continues from the start of the new content. Some teams focus a skip link or the main landmark instead. Whatever you choose, be consistent, skip it on the very first page load, and test with a screen reader.
4. Can I use @axe-core/react with React 19?
The @axe-core/react package states that it does not support React 18 and later, so it is not a good choice for current applications. Use axe-core in other ways instead: a browser extension built on axe while developing, jest-axe or a Vitest equivalent in component tests, and the Playwright or Cypress integrations in end-to-end tests. All of them run the same rules against the rendered page, which is what assistive technology actually uses.
5. Should I write my own modal and dropdown components?
Only if you have the time to match the keyboard and screen reader behaviour users expect, and to test it in several browsers and screen readers. For most teams it is better to use the native dialog element for modals and an established headless library for menus, comboboxes and tabs. These already contain focus, handle Escape and arrow keys and set ARIA states, and bugs are fixed for everyone who uses them.
6. Why does my React app lose focus after deleting an item?
Because the element that had focus, usually the delete button, was removed from the page. Browsers then move focus to the body, so the next Tab starts from the top and screen readers say nothing useful. Keep a ref to a sensible target, such as the next item, the previous item or the list heading, and focus it after the state update. Announce the result through an existing status region, for example Item removed from basket.
7. Do server components change anything for accessibility?
They change where code runs, not what the browser receives. Server components render HTML on the server, which is good for accessibility because content, headings and alt text are present before JavaScript loads. Interactive widgets still need to be client components. Make sure controls are real buttons and links in the server-rendered HTML, so they are at least recognisable, and that nothing important depends on hydration finishing before a user can understand the page.
The Accessibility Audit tests the rendered page against WCAG 2.2 level AA and lists each finding with the element that failed and the success criterion behind it, so you can trace each one to the component that produced it.
Open Accessibility Fixes โ