React Accessibility Issues That Audits Keep Finding in Next.js
The failures that come from single page apps rather than markup: silent route changes, clickable divs, drifting ARIA state, leaky modals and live regions that never speak.
You inherit a React or Next.js build. The markup looks tidy, the components are typed, and Lighthouse is green. Then somebody tabs through it with a screen reader running, and the page changes without a word, a menu cannot be opened from the keyboard, and the "Saved" message never gets read out.
None of that is a markup typo. These are failures that come from how single page apps work: the browser no longer loads a new page, so everything it used to do for free on a page load is now your code's job. This guide lists what audits of React and Next.js sites keep finding, the WCAG criterion each one breaks, and the fix.
1. Route changes nobody hears
On a normal website, clicking a link loads a new document. The screen reader announces the new page title, and focus starts at the top. With client side routing, the URL changes and the content swaps, but the browser sees no page load. A screen reader user who pressed Enter on a link can be left on the same spot with no idea anything happened.
What Next.js does for you
Next.js ships a route announcer by default. Its documentation says it "looks for the page name to announce by first inspecting document.title, then the <h1> element, and finally the URL pathname" (Next.js docs, Accessibility). So the most common Next.js failure here is not a missing announcer. It is every page sharing one title, such as "Acme" on every route, so the announcer reads the same word after every click.
// Broken: every route has the same title, so every navigation announces "Acme"
export const metadata = { title: "Acme" };
// Fixed: a unique title per page, set in each page or layout (App Router)
export const metadata = { title: "Pricing - Acme" };
A plain React app with a client side router does not get an announcer from React itself. Check what your router provides, and if it provides nothing, add a visually hidden live region that receives the new page title after each navigation.
Where focus should land
The Next.js docs describe an announcement, not a focus move. Announcing the page helps, but focus can still sit on a link in a navigation that is no longer relevant, or fall back to the top of the document. WCAG 2.4.3 Focus Order asks that focus moves in an order that "preserves meaning and operability" (W3C, Understanding 2.4.3). A common, predictable fix is to move focus to the new page's main heading after a route change:
// Fixed: after navigation, move focus to the page heading
"use client";
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}>{children}</h1>;
}
tabIndex={-1} lets the heading take focus from script without adding it to the tab order. Pick one rule and apply it on every route.
2. div onClick and the button it should have been
JSX makes it easy to put onClick on anything, so audits keep finding clickable divs, spans and icons. A mouse user cannot tell the difference. A keyboard user cannot reach the element, and a screen reader does not announce it as a control. That fails 2.1.1 Keyboard and 4.1.2 Name, Role, Value, which requires that for every user interface component "the name and role can be programmatically determined" (W3C, Understanding 4.1.2).
// Broken: no focus, no Enter or Space, announced as plain text
<div className="card__toggle" onClick={toggle}>
<ChevronIcon />
</div>
// Fixed: a real button, with a name and its state
<button type="button" className="card__toggle" onClick={toggle}
aria-expanded={open} aria-label="Show details">
<ChevronIcon aria-hidden="true" />
</button>
Adding role="button" and tabIndex={0} to the div is the long way round: you then have to handle Enter and Space yourself, and the next developer will forget one of them. A native button does it all. If the thing goes somewhere, it should be a link instead; our guide on button vs link accessibility has the rule for choosing.
Start with shared components: one <IconButton> built on a div repeats the failure on every page, and one fix clears them all.
3. Styling by ARIA state, not by a separate state variable
A quieter bug: the component tracks isOpen in React state, sets a class from it, and sets aria-expanded from something else, or forgets to set it at all. The panel looks open, but a screen reader says "collapsed". 4.1.2 also requires that states set by the user can be programmatically determined, so a mismatch is a failure, not a nicety.
The fix is to make the ARIA attribute the single source the styling reads from. If the CSS keys off aria-expanded, the visual state and the announced state cannot drift apart, because a missing attribute shows up as a visual bug that someone will notice.
/* Broken: the look depends on a class the ARIA attribute never sees */
.disclosure--open .disclosure__icon { rotate: 180deg; }
/* Fixed: the look depends on the attribute screen readers read */
.disclosure__button[aria-expanded="true"] .disclosure__icon { rotate: 180deg; }
The same idea works with class-variance-authority (CVA) and Tailwind, which is a common setup in Next.js projects. Instead of a CVA variant like open: { true: "rotate-180" } driven by a prop, put the state on the element and let the classes respond to it. Tailwind has variants for ARIA attributes, such as aria-expanded: and aria-selected:, and data-* variants for libraries that expose a data-state attribute (Tailwind docs, states).
// Broken: the variant and aria-expanded are set separately and can disagree
const trigger = cva("trigger", { variants: { open: { true: "bg-muted" } } });
<button className={trigger({ open: isOpen })}>Menu</button>
// Fixed: one value sets aria-expanded, and the classes read the attribute
const trigger = cva("trigger aria-expanded:bg-muted");
<button className={trigger()} aria-expanded={isOpen}>Menu</button>
4. Modals, portals and the focus that escapes them
React modals are usually rendered with createPortal into the end of document.body. The React docs warn that with portals "you may need to manage keyboard focus so that the user can move the focus in and out of the portal in a natural way", and point to the WAI-ARIA modal pattern (React docs, createPortal). Audits keep finding three gaps:
- Focus does not move in. The dialog opens, but focus stays on the button behind it. The WAI-ARIA pattern says focus moves to an element inside the dialog when it opens (W3C APG, dialog (modal) pattern).
- Focus escapes. Tab walks out of the dialog into the page behind it. The pattern says Tab and Shift+Tab cycle inside the dialog, and Escape closes it.
- Focus does not come back. On close, focus drops to the top of the page instead of returning to the element that opened the dialog.
The native <dialog> element handles more of this than most custom components. MDN notes that with showModal(), the rest of the page is made inert by the browser, Escape closes the dialog, and focus moves to the first focusable element inside it (MDN, dialog element). Returning focus on close is still worth testing by hand.
// Broken: a div overlay in a portal, with no focus handling
{open && createPortal(<div className="modal">{children}</div>, document.body)}
// Fixed: native dialog opened with showModal, focus returned on close
function Modal({ open, onClose, title, children }) {
const ref = useRef(null);
const opener = useRef(null);
useEffect(() => {
if (open) { opener.current = document.activeElement; ref.current.showModal(); }
else if (ref.current?.open) { ref.current.close(); opener.current?.focus(); }
}, [open]);
return (
<dialog ref={ref} onClose={onClose} aria-labelledby="modal-title">
<h2 id="modal-title">{title}</h2>
{children}
</dialog>
);
}
One more portal detail that surprises people: React events from a portal bubble through the React tree, not the DOM tree, so a click inside the modal can trigger an onClick on a parent component that is nowhere near it on the page (React docs, createPortal caveats). If closing a modal also fires a click behind it, this is usually why. Our guide to accessible modals covers the dialog pattern in full.
5. Live regions that are added too late
React apps are full of async state: "Saving...", "Added to cart", "3 results", form errors after submit. The usual fix is aria-live, and the usual bug is rendering the live region at the same moment as its message:
// Broken: the region and its text appear in the same render, often silent
{saved && <p aria-live="polite">Changes saved</p>}
// Fixed: the region is always there, only its text changes
<p role="status" aria-live="polite">{saved ? "Changes saved" : ""}</p>
MDN explains why: assistive technologies "will generally only announce dynamic changes in the content of a live region", so you should "establish the live region before updating its content", and "the most reliable way to ensure that live regions are registered is to include them in the initial markup" (MDN, ARIA live regions). In React terms: render the container unconditionally, and change only what is inside it. MDN also notes that role="alert" gets special handling and is generally announced even when injected, which is why it works for urgent errors but should not be used for routine status. Our guide to role="status" and role="alert" goes deeper.
6. What jest-axe and @axe-core/playwright catch, and what they miss
Adding axe to your tests is worth doing. It catches missing names, invalid ARIA, missing labels and similar problems on every build. Deque, which maintains axe-core, reports that 57.38% of issues found in first-time audits of its customers could be found with automated testing (Deque, automated testing coverage). The rest needs a person.
Two tool details matter for React projects:
- jest-axe runs in JSDOM, which does not render. Its README says "color contrast checks do not work in JSDOM so are turned off in jest-axe" (jest-axe on GitHub). Component tests will never flag contrast.
- @axe-core/playwright runs in a real browser, so it sees rendered styles. Playwright's own docs still say "automated testing cannot detect all types of WCAG violations" (Playwright docs, accessibility testing).
Four things from this guide that a passing axe run does not prove:
- Where focus goes after a route change. axe checks one state of the page, not what happens between two.
- Whether a live region actually speaks. axe can see the attribute, not whether the region existed before the text arrived.
- Whether a modal traps and returns focus. That is behaviour across key presses, which needs a scripted or manual test.
- Whether a clickable div is a control. Click handlers attached by React are not visible as HTML attributes, so a rule that reads the markup cannot see them. Look for them in code review, or use a check that tests the rendered page for clickable elements that are not controls.
For the first three, a short keyboard test by hand on each key template finds most of it in minutes.
How to explain this to a client or a manager
Keep it to one sentence per finding: "When someone navigates the site with a keyboard or a screen reader, the page changes without telling them", "the menu button cannot be opened without a mouse", "the save confirmation is shown but never read out". Then say where the fix lives. Most of these are fixed once in a shared component or layout, not page by page, which makes the estimate smaller than the finding list suggests.
Be clear that automated checks, ours included, find many problems but cannot confirm a site conforms to WCAG. The behaviours in this guide need a person with a keyboard to sign them off.
Checklist before you audit a React or Next.js build
- Every route has a unique, descriptive
title, and you know whether your router announces changes. - One agreed rule for where focus lands after navigation, applied everywhere.
- Search the code for
onClickondiv,span,liandsvg, and start with shared components. - Styles for open, selected and pressed states read
aria-*ordata-stateattributes, not a separate class. - Modals move focus in, keep it in, close on Escape and return it to the opener.
- Live regions are rendered on first load and only their text changes.
- axe runs in a real browser in CI, and a person tests the behaviour it cannot.
To see what a rendered page fails right now, run a free accessibility scan: AccessGuard runs automated WCAG 2.2 AA checks in a real browser, so it sees what your React code actually renders. For focused checks, check a page for controls a keyboard cannot reach or see, and check a page for broken ARIA.