Route Change Accessibility
When a browser performs a real navigation it does a great deal of work on the user’s behalf: it announces the new page, resets focus to the top of the document, updates the accessibility tree wholesale, and re-arms every “skip to content” affordance. A client-side router that swaps the DOM in place does none of that automatically. This guide covers what the browser stops doing the moment you call pushState, and the small, well-defined set of things your router must do to put it back.
← Back to Routing Architecture & Fundamentals
The Problem
The failure is silent, which is why it survives so long in production. A sighted mouse user clicks a link, the panel changes, and everything appears to work. A screen reader user clicks the same link and hears nothing at all. The virtual cursor stays parked where it was — often on the link that was just activated, in a navigation menu at the top of the page — while the content it describes no longer exists. There is no error, no visual glitch, and no automated test failure. The page simply became unusable for that person.
Keyboard users hit a related but distinct problem. After a client-side navigation, focus is wherever it was left. Pressing Tab continues from the old position, which is usually inside the navigation, so a keyboard user must tab past the entire menu again on every single route change. The “skip to main content” link that exists precisely to avoid this only helps if it is reachable, and after a DOM swap it is often behind the current focus position rather than in front of it.
Both problems come from the same root cause: history.pushState is deliberately inert. The specification is explicit that it updates the session history entry and nothing else — it fires no event, dispatches no navigation, and does not touch the accessibility tree. That inertness is what makes the History API useful as a low-level primitive, and it is exactly what makes routers built on it inaccessible by default.
It is worth being precise about why this is not merely a nicety. A screen reader user navigating a client-side application without announcements has no reliable way to know whether their click did anything. The usual coping strategy is to press the “read from top” command after every activation and listen until something sounds different — which turns a one-second interaction into a fifteen-second one, on every link, forever. The cost is not that the experience is degraded; it is that the application is slower to use by an order of magnitude for the people least able to absorb that cost.
The same reasoning applies to focus. Keyboard-only users are not a niche: the group includes people using switch devices, voice control, screen magnifiers that follow focus, and anyone whose trackpad has stopped working. When focus is stranded in the navigation after every route change, the tab distance to the primary action on a page grows by however many links the menu contains. On a site with a twenty-item menu that is twenty extra keystrokes per navigation, and it compounds with every screen the user visits.
A third problem hides behind the first two: the document title. Screen readers announce the title on a real navigation and users rely on it to distinguish tabs. Routers that update only the visible heading leave every route sharing the title of whichever page happened to load first, so a user with eight tabs open sees eight identical labels.
pushState deliberately skips — each one has to be re-implemented in the router.Core API & Primitives
The toolkit is small and entirely standard. No framework is required for any of it.
document.title. Setting it is the single highest-value line in an accessible router. Screen readers read the title when a page is announced, and browsers use it for tab labels, history entries, and bookmark names.
An ARIA live region. An element carrying aria-live="polite" whose text content changes causes assistive technology to announce that text without moving focus. One such region, mounted once at the app root and never re-created, is enough for every route in the application. The announcing route changes with aria-live page covers the timing subtleties that decide whether the announcement is actually spoken.
element.focus() with tabindex="-1". Moving focus to the new content is the other half of the story, and the one that serves keyboard users as well as screen reader users. A heading or main landmark is not focusable by default; tabindex="-1" makes it programmatically focusable without adding it to the tab order.
prefers-reduced-motion. Route transitions that animate must respect it. A user who has asked for reduced motion should get an instant swap, not a shortened animation.
// TypeScript 5.x — the complete primitive surface, no dependencies
interface RouteAnnouncement {
/** Document title for the new route, e.g. "Billing — Acme". */
title: string;
/** What assistive technology should hear, e.g. "Billing, page loaded". */
spoken: string;
/** Element to move focus to — usually the new <h1> or the <main> landmark. */
focusTarget: HTMLElement | null;
}
Two things deliberately absent from that list: role="alert" and aria-live="assertive". Both interrupt whatever the user is currently hearing. A route change is expected — the user asked for it — so it should queue politely behind the current utterance rather than cutting it off.
Step-by-Step Implementation
Prerequisite: a router that already resolves URLs to views and can run a callback after the new view has been committed to the DOM. The work below happens strictly after render, because focusing or announcing content that has not been inserted yet does nothing.
1. Mount a single live region at the app root
Create it once, outside the routed subtree, so it is never destroyed and re-created by a navigation. A live region that is inserted at the same moment its text is set is frequently missed by assistive technology, because the announcement machinery watches for changes to regions that already exist.
// TypeScript 5.x — one region for the whole application lifetime
function createRouteAnnouncer(): (message: string) => void {
const region = document.createElement("div");
region.setAttribute("aria-live", "polite");
region.setAttribute("aria-atomic", "true");
// Visually hidden, but NOT display:none — hidden elements are not announced.
region.className = "visually-hidden";
document.body.appendChild(region);
return (message: string) => {
// Clearing first guarantees a change event even for a repeated message.
region.textContent = "";
requestAnimationFrame(() => {
region.textContent = message;
});
};
}
2. Update the document title before anything else
Do it as soon as the route resolves, not after the view renders. If the navigation is slow, the tab label should already reflect where the user is going.
// TypeScript 5.x — title first, then everything else
function applyRouteTitle(routeTitle: string, siteName = "Acme"): string {
const full = `${routeTitle} — ${siteName}`;
document.title = full;
return full;
}
3. Move focus to the new content
Prefer the new <h1>: it identifies the page and reads naturally when announced. Fall back to the <main> landmark, then to the document body. Set tabindex="-1" on the target immediately before focusing so it never joins the tab order permanently, and remove it on blur to keep the DOM clean.
// TypeScript 5.x — focus the new view without polluting the tab order
function focusNewView(container: HTMLElement): HTMLElement | null {
const target =
container.querySelector<HTMLElement>("h1") ??
container.querySelector<HTMLElement>("main") ??
container;
target.setAttribute("tabindex", "-1");
target.addEventListener("blur", () => target.removeAttribute("tabindex"), { once: true });
// preventScroll: the router's own scroll restoration decides the position.
target.focus({ preventScroll: true });
return target;
}
4. Announce, then let scroll restoration run
Announce after focus has moved, so the two do not compete, and restore scroll last. The order matters: focusing an element scrolls it into view unless preventScroll is set, which is why step 3 sets it and why scroll restoration is applied afterwards rather than before.
// TypeScript 5.x — the post-navigation sequence, in order
const announce = createRouteAnnouncer();
export function completeNavigation(view: HTMLElement, routeTitle: string): void {
applyRouteTitle(routeTitle); // 1. tab label and history entry
const focused = focusNewView(view); // 2. keyboard and virtual cursor position
announce(`${routeTitle}, page loaded`); // 3. spoken confirmation
if (!focused) window.scrollTo(0, 0); // 4. fall back to the top of the document
}
5. Signal pending navigations that take time
If a route’s data takes longer than roughly a second to arrive, the user is left with no feedback at all. Mark the outgoing region aria-busy="true" while loading and announce a short “Loading billing” message, then clear both when the view commits. Do not announce progress repeatedly — one message at the start and one at the end.
Verification & Testing
Automated checks catch the structural mistakes; a manual pass with a real screen reader catches everything else. Both are necessary.
// @playwright/test v1.44 — the three assertions that matter most
import { test, expect } from "@playwright/test";
test("a client-side navigation is announced and focused", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "Billing" }).click();
// 1. The tab label changed — the cheapest signal that the route committed.
await expect(page).toHaveTitle(/^Billing —/);
// 2. Focus landed on the new heading, not on the link that was clicked.
const focusedText = await page.evaluate(() => document.activeElement?.textContent?.trim());
expect(focusedText).toBe("Billing");
// 3. The live region carries the announcement.
await expect(page.locator("[aria-live='polite']")).toHaveText(/Billing, page loaded/);
});
Run an automated audit as well — axe-core’s aria-allowed-attr, aria-hidden-focus and region rules catch live regions that are hidden with display: none (and therefore never announced) and focus targets that are inside aria-hidden containers. Neither issue is visible in a browser.
The manual pass is short and worth doing on every routing change. With VoiceOver or NVDA running, activate a link and listen for exactly one announcement naming the new page. Then press Tab once: the next stop should be inside the new content, not back at the top of the navigation. Finally, press the screen reader’s “read from cursor” command — it should start at the new page, which only happens if focus actually moved.
Performance Tuning
Accessibility work in a router is cheap, but the interactions with rendering are easy to get wrong.
Do not announce on every keystroke-driven URL update. Search-as-you-type routes that call replaceState on each character will fire an announcement per character if the announcement is wired to URL changes rather than to route commits. Announce on route identity change, and debounce the URL writes themselves as described in debouncing URL updates while typing.
Focus after paint, not before. Calling focus() synchronously inside the render pass can force a style and layout recalculation mid-render. Schedule it in a requestAnimationFrame callback after the commit so the browser batches the work.
Keep the live region out of the routed subtree. Beyond correctness, this avoids re-creating a node on every navigation — a small allocation, but one that happens on the critical path of every single transition.
Prefer one region to many. Multiple live regions competing to announce the same navigation produce doubled or garbled speech, and each one adds an accessibility-tree subscription the browser must maintain.
Pair transitions with prefers-reduced-motion. A cross-fade between routes costs a compositor frame and a repaint; for users who have requested reduced motion, skipping it is both the accessible and the faster path.
Assistive Technology Behaviour
The specifications describe what a live region should do; the useful knowledge is what the common combinations actually do, because they differ in ways that change the implementation.
| Combination | Live region announcement | Focus move announcement | Practical note |
|---|---|---|---|
| NVDA + Firefox | Reliable when the region pre-exists | Reads the focused element and its role | The most forgiving pairing; test here last, not first |
| NVDA + Chrome | Reliable, occasionally clipped if text is replaced twice quickly | Reliable | Clearing then setting in the next frame avoids the clipping |
| JAWS + Chrome | Reliable with aria-atomic="true" |
Reliable | Without aria-atomic only the changed text node is read |
| VoiceOver + Safari | Sometimes skipped if the region was just inserted | Very reliable | Focus movement is the primary signal on this pairing |
| VoiceOver + iOS | Announces, but focus is the stronger cue | Moves the rotor position | Touch users depend far more on focus than on the region |
| TalkBack + Chrome | Polite regions are queued behind current speech | Reliable | Keep messages short; long ones are truncated |
Two design conclusions follow. First, do both — announce and move focus — because no single mechanism is reliable across all of them, and together they degrade gracefully into each other. Second, keep the announcement short and put the page name first: several combinations truncate or interrupt long messages, and a user who hears only the first two words should still know where they are.
The same table explains why a common shortcut fails. Some teams skip the live region entirely and rely on focusing the heading, reasoning that the focus announcement reads the heading text anyway. That works on VoiceOver and NVDA, but on TalkBack and in some JAWS configurations the heading is read without any indication that a navigation occurred, so the user hears a heading and cannot tell whether it is new content or the same content re-read.
Gotchas & Failure Modes
display: nonesilences a live region. Hide it with the clip-based visually-hidden pattern instead; anything the browser considers non-rendered is also non-announced.- Setting the region’s text in the same tick it is inserted usually announces nothing. Assistive technology needs the region to exist before the text changes — hence the
requestAnimationFramein step 1. - Focusing a container with no accessible name reads as “group”. Focus the heading, or give the container an
aria-labelledbypointing at the heading. tabindex="-1"left on an element permanently is harmless but confusing in the accessibility tree; removing it on blur keeps the DOM honest.aria-live="assertive"for routes interrupts the user mid-sentence. Reserve assertive for genuine emergencies such as a session expiring.- Modal routes need extra work. A route that renders a dialog must also trap focus and restore it to the trigger on close — announcing the route is necessary but not sufficient.
- Titles set only in a framework’s head manager may lag by a frame. Verify with
document.titlein a test rather than trusting the abstraction.
Related
- Announcing Route Changes with aria-live — building the live region, choosing the message, and the timing that decides whether it is spoken at all.
- Managing Focus After a Route Change — picking the focus target, avoiding scroll jumps, and handling back-navigation.
- Routing Architecture & Fundamentals — where accessibility sits alongside matching, nesting, and fallback behaviour.
- Scroll Restoration Strategies — the step that runs immediately after focus, and why the two must not fight.
- Nested & Layout Routing — which level of the tree the focus target should live in when only part of the screen changes.