+1 (781) 556-6029

Accessibility in a React app: focus, route announcements, and what axe cannot see

Accessibility work on a React codebase usually starts the same way: someone runs an automated scan, gets a list of colour-contrast failures and missing alt attributes, fixes them, and reports the app as accessible. Then a keyboard user tries to navigate it.

The scan was not wrong. It was narrow. Deque's own published estimate for axe-core is that automated rules catch around a third of WCAG issues on a page, and the rules are written to be almost free of false positives, which means they stay away from anything requiring judgement. The defects a React app introduces — focus that lands nowhere after a route change, a dialog that does not trap the keyboard, a list that updates silently — are exactly the judgement cases. They are also the defects that make the app unusable rather than merely non-conforming.

This post is about that second category: what single-page rendering breaks, how to detect it, and what to do.

Why React apps fail differently

A server-rendered page navigation resets focus to the document, and the browser announces the new page title. Client-side routing does neither. React replaces the subtree, the URL changes, the screen reader keeps reading from wherever its virtual cursor was, and keyboard focus stays on a link that no longer exists — at which point most browsers drop focus to <body> and the next Tab starts from the top of the document.

Three React patterns produce most of the rest:

  • Conditional rendering. {error && <p>{error}</p>} is invisible to a screen reader unless it is inside a live region that existed before the error did.
  • Portals. createPortal moves DOM, not the accessibility tree's keyboard order expectations. A modal rendered at the end of <body> is reachable by Tab from the trigger only by accident.
  • Composition. A Button that spreads ...rest onto a <div> because someone needed a custom shape loses its role, its Enter/Space handling, and its tab stop, and no scan flags it as a broken button because it is not a button.

None of this is a React defect. It is the consequence of owning navigation and DOM lifecycle in application code.

Focus on route change

The fix is not window.scrollTo(0, 0). Move focus deliberately, to a target that describes where the user now is.

The pattern we use is a route announcer that focuses the page heading after each navigation:

function RouteFocus({ children }: { children: React.ReactNode }) {
  const pathname = usePathname(); // next/navigation, or useLocation() in React Router
  const ref = useRef<HTMLDivElement>(null);
  const first = useRef(true);

  useEffect(() => {
    if (first.current) {
      first.current = false; // do not steal focus on initial load
      return;
    }
    const heading = ref.current?.querySelector<HTMLElement>('h1');
    (heading ?? ref.current)?.focus();
  }, [pathname]);

  return (
    <div ref={ref} tabIndex={-1}>
      {children}
    </div>
  );
}

Two details matter. The target needs tabIndex={-1} or it cannot receive programmatic focus; headings are not focusable by default. And the first render must be exempt, or you break the browser's own deep-link behaviour and any in-page fragment the user arrived with.

Skip the effect entirely when the navigation is a filter change that keeps the user in place — a query-string update on the same route is not a page change, and yanking focus to the heading on every facet click is worse than doing nothing. Key the effect on the path segment you consider a page, not on the whole URL.

Next.js ships a built-in route announcer that reads the new document title into a live region. It helps screen reader users and does nothing for keyboard users, so it is a complement to the above, not a replacement. React Router has no equivalent; you write it.

Announcing things that change without navigating

A live region only announces mutations that happen while it is already in the accessibility tree. This is the single most common mistake in React a11y code:

// Usually silent: the region and its content arrive together.
{status && <div role="status">{status}</div>}

// Announces: the region is mounted and empty, then filled.
<div role="status" aria-live="polite">{status}</div>

Render the container unconditionally near the root of the view and write text into it. role="status" (polite) for search result counts, save confirmations, and async completion; role="alert" (assertive) only for things that justify interrupting, which is mostly form submission errors and session expiry.

For form validation, the live region is secondary. The primary fix is aria-describedby wiring the input to its error text and aria-invalid on the field, plus moving focus to the first invalid input on a failed submit. React 19's useActionState makes this straightforward because the error state comes back from the action with the render, so the focus effect can key on the returned state object rather than guessing when submission finished.

Stop writing your own dialogs, menus, and comboboxes

The accessible-name computation, focus trap, inert background, arrow-key roving tabindex, type-ahead, and Escape semantics for a combobox run to several hundred lines, all of which have already been written and tested against real screen readers. Use a headless library — React Aria Components, Radix UI, or Base UI — and spend the saved time on the parts specific to your product.

If the component is already hand-rolled, the cheapest intermediate step is <dialog> for modals: the platform element gives you the focus trap, the Escape handling, and background inertness via showModal(). It is supported everywhere relevant now and is a smaller change than adopting a library across a design system in one pass.

Where a library is genuinely not an option, scope the work. Pick the three widgets that carry the most traffic, convert those, and leave the rest documented as known gaps rather than half-fixing everything.

A test setup that catches the rest

Four layers, in increasing cost and increasing yield:

  1. Lint. eslint-plugin-jsx-a11y catches static JSX mistakes — a click handler on a <div>, an <img> with no alt, a label with no control — before review. Cheap, run it in CI.
  2. Component tests. jest-axe or vitest-axe over rendered components in Testing Library. The real value here is not the axe assertion; it is that Testing Library queries are role-based, so a component with a broken role fails the query and the test turns red for the right reason.
  3. Keyboard tests. userEvent.tab() through a component and assert on document.activeElement. This is where dialogs, menus, and your route-focus logic are actually verified. Nothing else catches a focus trap regression.
  4. Manual screen reader passes. One hour per release on the two or three primary flows — VoiceOver with Safari, NVDA with Firefox or Chrome. No automation substitutes for this, and it is where you discover that your aria-label reads as nonsense out of context.

Run axe on real pages too, not only components, with a full-page run in Playwright via @axe-core/playwright. Page-level runs catch landmark, heading-order, and duplicate-id problems that component tests structurally cannot see.

Sequencing the work

For an existing app with a backlog of findings, we order it by whether the defect blocks a task or merely degrades it:

  1. Keyboard traps and unreachable controls — anything that stops a user completing a flow.
  2. Focus management on route change and in dialogs.
  3. Form labelling, error association, and invalid-state announcement.
  4. Live regions for async status.
  5. Contrast, alt text, heading order — real conformance issues, but a scan finds them and a developer fixes them in bulk.

That ordering is deliberately the inverse of what an automated report hands you. The scan sorts by what it can detect; this sorts by what a user hits first.

The European Accessibility Act's June 2025 compliance date has made this a procurement question for a lot of product teams rather than an engineering preference, and the practical consequence is that the conformance report now needs to be true. Automated coverage of a third of the rule set does not get you there.

If you want the audit done for you

Accessibility review is part of our React architecture & performance review: findings prioritized by user impact, each with the trace, the failing keyboard path, or the screen reader transcript behind it, and the remediation sequenced as increments against your routes. If you would rather run it yourself, the four test layers above are the whole of what we set up on the first week of an engagement.