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.
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.
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: hiddenand thehiddenattribute 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
requestAnimationFramein 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
replaceStatewrites 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
assertivefor 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.
Related
- Route Change Accessibility — the full set of things a client-side navigation must re-implement.
- Managing Focus After a Route Change — the complementary half that serves keyboard users as well.
- Nested & Layout Routing — deciding which level owns the announcement when only part of the screen changed.