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.

What the browser does on a real navigation versus a pushState navigation Two columns. On a full document load the browser announces the page, resets focus to the document root, rebuilds the accessibility tree and re-arms skip links. On a pushState navigation none of those happen, and each must be re-implemented by the router. Full document load — the browser does it Announces the new document title Resets focus to the document root Rebuilds the accessibility tree Puts the skip link back in front of focus pushState navigation — none of it happens Title unchanged unless you set it Focus stays on the activated link Tree is patched, no navigation signal Skip link is now behind the cursor Everything in the right-hand column is your router's responsibility.
A real navigation triggers four browser behaviours that 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.

The order of operations after a client-side navigation A five-step sequence: set the document title, render the view, move focus to the heading with scrolling prevented, announce the route in the live region, then restore the scroll position. After the route resolves 1 · Title document.title 2 · Render commit the view 3 · Focus h1, preventScroll 4 · Announce aria-live polite 5 · Scroll restore Why this order Title first, so a slow route already labels the tab correctly. Focus before announcing, so the two do not compete for the speech queue. Scroll last, because focus() with preventScroll leaves the position to the router.
Title, render, focus, announce, scroll — the sequence that reproduces what a real document load gives you for free.

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.

Why announcement and focus are complementary, not alternative A coverage matrix showing that a live region alone leaves some combinations silent, focus movement alone leaves others unable to tell a navigation occurred, and doing both covers every combination. Coverage across screen reader and browser pairings live region only NVDA · JAWS — spoken VoiceOver — sometimes skipped keyboard users — no help at all focus stays in the menu focus move only VoiceOver · NVDA — reads the heading TalkBack — no navigation cue keyboard users — fully served "was that new content?" both every pairing covered keyboard users served each degrades into the other roughly twenty extra lines
Neither mechanism covers every pairing on its own, and together they cost about twenty lines — which is why the answer is always both.

Gotchas & Failure Modes

  • display: none silences 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 requestAnimationFrame in step 1.
  • Focusing a container with no accessible name reads as “group”. Focus the heading, or give the container an aria-labelledby pointing 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.title in a test rather than trusting the abstraction.
What deserves an announcement and what does not Five events classified by whether they should be announced: route changes and slow loads yes, in-place filtering and per-keystroke updates no, and a session expiry as the rare assertive case. What to announce, and what not to announce? why a route change yes, politely the user asked for it and needs confirmation a slow route still loading once, after ~1s silence reads as a failed click a filter applied in place no — update a results count instead not a navigation each keystroke in a search box never one announcement per character is unusable a session expiring assertive, interrupting a genuine emergency
The two red rows are where announcers become unusable — announce route identity changes, not URL writes.