/ โ† Accessibility Fixes / React & Next.js
In this guide
  1. How React apps fail differently from ordinary sites
  2. Tracing a finding back to its component
  3. The fix, step by step
  4. Code you can adapt
  5. Next.js specifics
  6. Choosing component libraries
  7. Testing strategy that fits a component codebase
  8. Edge cases
  9. Questions and answers

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.

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.

  1. 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.
  2. 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.
  3. 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.
  4. Check server-rendered output too. In Next.js, view the page source to see the HTML sent before JavaScript runs. Missing lang attributes, titles and alt text are often visible there.
๐Ÿ’ก Test in a production build as well as in development. Development mode can render extra wrappers and warnings, and some problems, such as focus lost during hydration, only show up with real network timing.

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

AreaWhat to check
Page languageSet 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 titlesExport 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.
LinksUse the Link component, which renders a real anchor. Do not wrap it in a clickable div or nest a button inside it.
ImagesThe Image component expects an alt prop. Pass meaningful text, or an empty string for decorative images.
Loading statesloading.js skeletons and Suspense fallbacks should include a text alternative such as "Loading products", announced through a status region.
Server and client componentsInteractive 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:

โš ๏ธ A library only helps if you use it as intended. Wrapping its trigger in your own div, overriding its role, or rendering it with an empty label undoes the work. When the checker flags a library component, check the props you passed before assuming the library is at fault.

Testing strategy that fits a component codebase

  1. 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/react console helper does not support React 18 and later, so do not rely on it for current apps.
  2. In unit and component tests: query by role and accessible name with Testing Library, and assert no axe violations on rendered components with jest-axe or an equivalent for Vitest.
  3. 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.
  4. 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.

Questions and answers

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.

Check your React or Next.js pages

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 โ†’
๐Ÿ’ฌ Got a problem?
Related Guides: All Accessibility Fixes  ·  Fixing Keyboard Navigation  ·  Fixing Form Labels  ·  Fixing ARIA Labels  ·  Learning Hub

About aiwebpageseo

aiwebpageseo.com is a data-driven SEO and AEO (Answer Engine Optimisation) platform providing a free suite of technical SEO tools. Rather than relying on AI-theorised assumptions, the platform analyses live URL performance, delivering objective diagnostics, page speed metrics, CLS debugging, and site crawl data alongside actionable technical tutorials.