react nextjs accessibility patterns
Announcing Client-Side Route Changes in React
When a user clicks a server-rendered link, the browser performs a full page load: it resets focus to the top of the document, reads the new <title>, and screen readers announce the new page. Single-page applications break every part of that contract. Client-side routing swaps the DOM in place, so focus stays wherever it was, the title may never change, and the screen reader says nothing at all. The user activated a link and—from their perspective—nothing happened. This guide fixes that with three coordinated pieces: an aria-live route announcer, focus restoration to the main heading, and a document.title update on every navigation. These patterns belong to the Dynamic Content & State Announcements and the broader React & Next.js Accessibility Patterns set, and they satisfy WCAG 4.1.3 Status Messages and 2.4.3 Focus Order.
Why Client-Side Routing Is Silent to Assistive Technology
A traditional navigation is a discrete, observable event. The browser tears down the document and builds a new one; assistive technology treats that as "you are now on a new page" and announces the title. A client-side route change is just a sequence of React state updates and DOM mutations. There is no page-load event, no automatic focus reset, and no implicit title announcement. Three concrete failures result:
- No announcement. Nothing tells a screen reader user that navigation succeeded or which page they reached.
- Stranded focus. Focus remains on the link they just activated, which may now be in a stale or removed part of the tree. Their next Tab continues from an unexpected place, violating
2.4.3 Focus Order. - Stale title. Browser tab, history, and SR page identification all rely on
document.title. If it never updates, every page sounds identical.
The fix is to manufacture the signals the browser used to provide—explicitly, on every route change.
Prerequisites
- React 18+, with either React Router v6+ or Next.js App Router (
usePathname). - A single, stable layout shell that wraps every route—this is where the announcer and focus target live.
- A semantic landmark structure: one
<main>element and a single page-level<h1>per route. Focus restoration depends on these existing. - A
.sr-onlyutility class (visually hidden, notdisplay:none) for the announcer region.
/* Visually hidden but available to assistive technology. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
The Route Announcer
Mount one persistent polite live region in your root layout. It exists from first paint and is empty until a route change writes the new page name into it—satisfying the rule that live regions must pre-exist before they receive content, per 4.1.3 Status Messages.
'use client';
import { useEffect, useRef, useState } from 'react';
export function RouteAnnouncer({ pathname }: { pathname: string }) {
const [message, setMessage] = useState('');
const firstRender = useRef(true);
useEffect(() => {
// Skip the initial mount—the browser already announced the first load.
if (firstRender.current) {
firstRender.current = false;
return;
}
// Prefer the document title (set per route) for a human-readable label.
const label = document.title || pathname;
// Clear then set on the next frame so identical paths still re-announce.
setMessage('');
const id = requestAnimationFrame(() => setMessage(`Navigated to ${label}`));
return () => cancelAnimationFrame(id);
}, [pathname]);
return (
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{message}
</div>
);
}
The clear-then-set on a fresh animation frame guarantees the DOM text node actually changes even when navigating between routes with the same title, which is what forces the announcement to repeat.
The sequence below shows the order of operations on a single navigation.
Restoring Focus to the Main Heading
Announcing the page is necessary but not sufficient—focus must also move somewhere sensible so the user's next keystroke continues from the new page's start, satisfying 2.4.3 Focus Order. The conventional target is the page-level <h1>. Give it tabIndex={-1} so it is programmatically focusable without becoming a Tab stop, then focus it after each navigation. Do not move focus into <main> if you also use a skip link that targets <main>; pick one consistent destination.
'use client';
import { useEffect, useRef } from 'react';
export function PageHeading({ children, pathname }: { children: React.ReactNode; pathname: string }) {
const headingRef = useRef<HTMLHeadingElement>(null);
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return; // Leave initial focus alone on first load.
}
headingRef.current?.focus();
}, [pathname]);
return (
// tabIndex={-1} makes it focusable in code but not in the Tab order.
<h1 ref={headingRef} tabIndex={-1} style={{ outline: 'none' }}>
{children}
</h1>
);
}
Removing the default focus outline on the heading is acceptable here because the heading is not a Tab stop—the user did not Tab to it. If your design benefits from a visible cue when focus lands, keep a subtle outline instead of removing it. Cross-reference the deeper treatment in Handling Focus Restoration After Dynamic Route Changes for edge cases like scroll restoration and nested layouts.
Wiring It Up: React Router and Next.js
Both routers expose the current location reactively. The integration is the same shape—observe the path, set the title, render the announcer and heading.
React Router (v6+):
'use client';
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';
import { RouteAnnouncer } from './RouteAnnouncer';
const TITLES: Record<string, string> = {
'/': 'Home — Acme',
'/billing': 'Billing — Acme',
};
export function AppShell({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
useEffect(() => {
document.title = TITLES[pathname] ?? 'Acme'; // Update title first.
}, [pathname]);
return (
<>
<RouteAnnouncer pathname={pathname} />
<main>{children}</main>
</>
);
}
Next.js App Router uses usePathname(). Per-route titles are best set with the framework's Metadata API (export const metadata or generateMetadata), which updates document.title for you; the announcer then reads that title:
'use client';
import { usePathname } from 'next/navigation';
import { RouteAnnouncer } from './RouteAnnouncer';
export function RootClientShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<>
<RouteAnnouncer pathname={pathname} />
{children}
</>
);
}
Note that Next.js ships its own built-in route announcer in the App Router. If you add your own, verify in a screen reader that you are not double-announcing; prefer relying on the framework's announcer for the page name and reserving any custom region for additional context. Pair this with a skip link as described in Implementing Skip Links in Next.js App Router so keyboard users can bypass repeated navigation after each route change.
How to Verify
axe / jest-axe. Assert the announcer renders a polite status region and that text updates after navigation:
import { render, screen, act } from '@testing-library/react';
import { axe } from 'jest-axe';
import { RouteAnnouncer } from './RouteAnnouncer';
test('announcer updates and has no violations', async () => {
const { container, rerender } = render(<RouteAnnouncer pathname="/" />);
document.title = 'Billing — Acme';
await act(async () => {
rerender(<RouteAnnouncer pathname="/billing" />);
});
await screen.findByText(/Navigated to Billing/);
expect(await axe(container)).toHaveNoViolations();
});
Screen reader. With NVDA + Firefox and VoiceOver + Safari, activate in-app links and confirm each navigation is announced with the destination page name. Confirm the first page load is not double-announced. Navigate between two pages that share a title and confirm it still re-announces.
Keyboard. Tab to a link, activate it, then press Tab again and confirm focus continues from the new page's heading region—not from a stale position. Confirm the <h1> receives focus without becoming a redundant Tab stop on subsequent tabbing.
Common a11y Mistakes
- No live region at all. Relying on the title change alone is unreliable across AT; mount an explicit polite region.
- Creating the region on navigation instead of mounting it persistently. Late-inserted live regions are frequently missed; render it empty at app start.
- Announcing on first load. Double-announcing the initial page is noisy. Skip the first effect run.
- Forgetting focus restoration. Announcing the page but leaving focus on the clicked link fails
2.4.3 Focus Orderand strands keyboard users. - Using
assertivefor routine navigation. Route changes are not emergencies—usearia-live="polite". - Double announcers in Next.js. Adding a custom announcer on top of the framework's built-in one produces stuttering, repeated speech.
Conclusion
SPA navigation only feels broken to screen reader and keyboard users because the framework removed the browser's built-in cues without replacing them. Restore all three—a persistent polite announcer (4.1.3 Status Messages), focus moved to the page heading (2.4.3 Focus Order), and a fresh document.title—and client-side routing becomes as legible to assistive technology as a full page load, while keeping the speed of an SPA.
Frequently Asked Questions
Why don't screen readers announce route changes in a React SPA automatically?
Because there is no page-load event. A full navigation tears down and rebuilds the document, which assistive technology treats as a new page and announces. Client-side routing only mutates the existing DOM, so there is no load event, no automatic focus reset, and no implied title announcement. You must manufacture these signals yourself with a live region, focus management, and a title update.
Should I move focus to the main element or the h1 after navigation?
Move it to the page-level <h1> with tabIndex={-1}, or consistently to <main>—but pick one and apply it everywhere. The heading is usually the better choice because it reads the page name as focus lands. Avoid moving focus to <main> if your skip link already targets <main>, to prevent conflicting destinations.
Do I need a custom route announcer in Next.js App Router?
Often not—Next.js ships a built-in route announcer in the App Router that reads the page title on navigation. Set per-route titles with the Metadata API and verify in a real screen reader. Only add a custom region if you need to announce extra context, and confirm you are not double-announcing the page name.
How do I make navigation between two pages with the same title still announce?
Clear the live region's text and re-set it on a fresh animation frame (or after a microtask). Because the text node value changes from empty back to a string, assistive technology detects a mutation and announces again, even when the destination title is identical to the previous one.