Announcing Route Changes with aria-live

After reading this you will be able to build a route announcer that is reliably spoken by NVDA, JAWS, VoiceOver and TalkBack — and to recognise the four implementation mistakes that produce a live region which looks correct and says nothing.

← Back to Route Change Accessibility

Prerequisites

Core Concept

An ARIA live region is an element whose text changes are announced by assistive technology without moving focus. aria-live="polite" queues the announcement behind whatever is currently being spoken; assertive interrupts. A route change is something the user requested, so it is always polite.

The mechanism is deceptively simple and has one non-obvious requirement: the region must already exist in the accessibility tree before its text changes. Assistive technology subscribes to mutations of regions it knows about. Creating an element and setting its text in the same task is, to that subscription, a single insertion — and insertions of new subtrees are frequently not announced at all. This is the single largest cause of “my live region does not work”.

Three further properties matter. aria-atomic="true" makes the whole region read as one utterance rather than only the changed text node — without it, JAWS in particular reads fragments. The region must be perceivable: display: none, visibility: hidden and the hidden attribute all remove it from the accessibility tree, so the visually-hidden pattern must use clipping instead. And repeated identical text does not fire a change, so navigating twice to the same title needs the content cleared first.

Finally, the announcement is only half of an accessible navigation. Focus movement is the other half, and the two are complementary rather than alternative — several screen reader and browser combinations depend more on one than the other, as the route change accessibility overview sets out.

Why a just-inserted live region is not announced Two timelines. In the failing case the region is created and its text set in the same task, so assistive technology sees one insertion and announces nothing. In the working case the region exists from application start, so the later text change is a mutation it is already subscribed to. Created and filled together — usually silent createElement + textContent same task AT sees one insertion no subscription existed yet nothing is spoken Region exists from start — reliably spoken region mounted at boot empty, outside the routed tree text set on navigation a mutation, not an insertion "Billing, page loaded" The region is infrastructure, not part of the page — mount it once and never unmount it.
Assistive technology announces changes to regions it already knows about; a region created at the moment of the change is an insertion, not a change.

Implementation

The region is created once at application start, outside the routed subtree so no navigation can destroy it.

// TypeScript 5.x — a route announcer, no dependencies

export function createRouteAnnouncer(): (message: string) => void {
  const region = document.createElement("div");
  region.id = "route-announcer";
  region.setAttribute("aria-live", "polite");
  // Read the whole region as one utterance rather than only the changed node.
  region.setAttribute("aria-atomic", "true");
  // role="status" gives older screen readers an implicit polite live region.
  region.setAttribute("role", "status");
  region.className = "visually-hidden";
  document.body.append(region);

  let clearTimer: number | undefined;

  return (message: string) => {
    window.clearTimeout(clearTimer);
    // Clearing guarantees a change event even when the message repeats.
    region.textContent = "";
    requestAnimationFrame(() => {
      region.textContent = message;
      // Empty it again so a later focus into the region reads nothing stale.
      clearTimer = window.setTimeout(() => (region.textContent = ""), 1000);
    });
  };
}

The CSS matters as much as the markup. Anything that removes the element from the rendering tree also removes it from the accessibility tree:

/* Clip, do not hide. display:none and visibility:hidden are NOT announced. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

Wiring it to the router, after the view has been committed:

// TypeScript 5.x — announce on route commit, not on URL change

const announce = createRouteAnnouncer();
let lastAnnounced: string | null = null;

export function announceRoute(routeTitle: string): void {
  // Guard against replaceState-driven URL churn: only a NEW route is announced.
  if (routeTitle === lastAnnounced) return;
  lastAnnounced = routeTitle;

  // Page name first: several combinations truncate long messages.
  announce(`${routeTitle}, page loaded`);
}

export function announcePending(routeTitle: string): void {
  announce(`Loading ${routeTitle}`);
}

Wording is a real design decision, not boilerplate. Three rules cover it: put the page name first, keep the whole message under about eight words, and use a consistent suffix so regular users learn to stop listening after the name. “Billing, page loaded” beats “You have navigated to the billing page of your account”, which several combinations will cut off before the useful word.

Announcement message anatomy A message broken into parts: the page name first, then a short consistent suffix. Alongside it, three rejected alternatives showing a message that is too long, one that omits the page name, and one that uses assertive politeness. Good "Billing" page name, spoken first ", page loaded" short, identical every time 3 words · polite · name first Rejected "You have successfully navigated to the billing section of your account settings" — truncated before "billing" on some devices "Page loaded" — says a navigation happened but not where to; the user still has to go and find out
Name first, suffix short and constant: regular users stop listening once they have heard the page name.

Verification

// @playwright/test v1.44 — structural checks that catch the silent failures
import { test, expect } from "@playwright/test";

test("the announcer is present, perceivable, and updated on navigation", async ({ page }) => {
  await page.goto("/");

  const region = page.locator("#route-announcer");
  await expect(region).toHaveAttribute("aria-live", "polite");
  await expect(region).toHaveAttribute("aria-atomic", "true");

  // It must NOT be display:none — that would remove it from the a11y tree.
  const display = await region.evaluate((el) => getComputedStyle(el).display);
  expect(display).not.toBe("none");

  await page.getByRole("link", { name: "Billing" }).click();
  await expect(region).toHaveText(/^Billing, page loaded$/);
});

The structural test cannot tell you whether anything was spoken, so a manual pass is still required. With NVDA or VoiceOver running, activate a link and listen: you should hear exactly one announcement containing the page name. Two announcements means a duplicate region — often a framework’s built-in announcer plus your own. Silence means either the insertion-timing bug or a hiding rule that removed the region from the tree.

One further check catches a subtle regression: navigate twice to the same route from different links and confirm the announcement is spoken both times. If the second navigation is silent, the clear-then-set step has been optimised away somewhere — a common casualty of a framework upgrade that starts batching DOM writes differently. It is worth a dedicated test, because the failure only appears on repeat visits and never during the manual pass a developer does after implementing the feature.

Gotchas

  • display: none, visibility: hidden and the hidden attribute all silence a live region. Only clipping keeps it perceivable while invisible.
  • Setting text in the same task as insertion is usually not announced. The requestAnimationFrame in the implementation exists solely for this.
  • Identical consecutive messages do not fire a change. Clear the region first, or the second visit to the same page is silent.
  • Frameworks may already ship an announcer. Next.js and React Router both include one; adding a second produces doubled speech that reads as a bug in your app.
  • Do not announce on every URL change. Filter-driven replaceState writes are not navigations; announce on route identity change only.
  • Long messages are truncated on mobile screen readers. Keep the whole utterance short enough to survive.
  • Never use assertive for routing. It interrupts the user mid-sentence for an event they initiated.

FAQ

Should I use role="status" or aria-live="polite"? Both, together. role="status" carries an implicit polite live region and improves support in older combinations; the explicit attribute makes the intent obvious to anyone reading the markup.

Is announcing enough on its own? No. Some combinations depend far more on focus movement, so pair it with managing focus after a route change. Together they degrade into each other; alone, each has gaps.

What should a loading state announce? One short message when a route takes more than about a second — “Loading billing” — and the normal completion message when it commits. Never announce progress repeatedly.

Where exactly should the region live in the DOM? Directly under <body>, outside every routed container, so no navigation can unmount it. Its position does not affect the announcement.

Does the announcement text need to match the document title? Not exactly, but they should agree. Using the same page name in both means a user who checks the tab and a user who hears the announcement get the same answer.

The four causes of a live region that never speaks Four failure causes with their distinguishing symptom and fix: hiding with display none, setting text in the same tick as insertion, repeating an identical message, and mounting the region inside the routed subtree. Four ways a live region ends up silent symptom fix display: none on the region never announced anywhere clip instead of hiding text set in the insertion tick silent on first navigation only set it in the next frame identical repeated message silent on the second visit clear the region first region inside the routed subtree silent after the first swap mount it at the app root
Each cause has a distinct symptom, which is what makes a silent announcer diagnosable rather than mysterious.