A full page load announces itself: the browser fires a navigation event and the screen reader reads
the new document title. A client-side navigation does neither. React Router swaps the route, the page
repaints, and a screen reader user hears nothing. This recipe pairs a persistent
LiveRegion with useLocation() so every completed navigation
publishes Navigated to {page}.
This site runs the exact file below. Turn on a screen reader and click through the sidebar to hear it.
Why a recipe, not a component
The announcer needs one thing mantle cannot own: your router. useLocation here is React Router's;
with another router, read the pathname from its own location API. TanStack Router's useLocation()
returns a location object, so the destructure carries over; Next.js's usePathname() returns the
pathname string itself. Mantle ships the announcement mechanics as
LiveRegion; this file is the router glue, and it lives in your
app.
The message
The announcement reads Navigated to {page}. The prefix marks the message as a navigation. Without
it, a bare page name lands mid-stream with no context and reads as unrelated status text.
The page name resolves in order:
The page <h1>. The most accurate name for where the user landed.
document.title. The fallback when a page renders no heading. It can lag: a route without a
meta export keeps the previous page's title, which is why the heading wins.
The pathname. The last resort, so the announcement never goes out empty.
Deliberate non-behaviors
Three silences are on purpose:
The initial document load stays silent. The screen reader reads the document title itself on a
full load; announcing it again doubles it.
Search-param and hash changes stay silent. They are in-page state, not a new page. The effect
keys on pathname alone, so ?tab=examples and #api-reference never announce.
Focus never moves. A focus move announces on its own, and the two announcements would race.
Announcing and moving focus are different recipes; this one only speaks.
Clearing before publishing
A live region only announces a DOM change, and consecutive pages can resolve to the same text: two
detail pages titled Alert, or two routes sharing a fallback title. Writing the same string again is
not a change, so the second navigation stays silent. The effect clears the region first, then
publishes on the next frame, so every navigation mutates the DOM even when the text repeats.
The component
This is the one file to copy. Render it once, inside the router context in the root layout:
1import { LiveRegion } from "@ngrok/mantle/live-region";2import { useEffect, useRef, useState } from "react";3import { useLocation } from "react-router";45/**6 * Announces completed client-side navigations to assistive technology.7 *8 * React Router swaps the route without a document load, so screen readers9 * hear nothing. This component publishes "Navigated to {page}" to a10 * persistent polite `LiveRegion` after each pathname change. The prefix marks11 * the message as a navigation, so it does not read as unrelated status text.12 *13 * The announcement prefers the page `<h1>` over `document.title` because14 * routes without a `meta` export keep the previous document title. The15 * pathname is the last resort.16 *17 * Deliberate non-behaviors:18 * - The initial document load stays silent; assistive technology reads the19 * document title itself.20 * - Search-param and hash changes stay silent; they are in-page state.21 * - Focus never moves; a focus move announces on its own, and the two22 * announcements would race.23 *24 * Render it once, inside the router context in the root layout.25 *26 * @example27 * ```tsx28 * <RouteAnnouncer />29 * <ScrollRestoration />30 * <Scripts />31 * ```32 */33function RouteAnnouncer() {⋯34 const { pathname } = useLocation();35 const [announcement, setAnnouncement] = useState("");36 const previousPathname = useRef(pathname);3738 useEffect(() => {⋯39 if (previousPathname.current === pathname) {⋯40 return;41 }42 previousPathname.current = pathname;4344 const pageHeading = document.querySelector("h1")?.textContent?.trim();45 const pageName = pageHeading || document.title || pathname;46 const message = `Navigated to ${pageName}`;4748 // Clear first, then publish on the next frame. Consecutive pages can49 // resolve to the same text, and a live region only announces a DOM change.50 setAnnouncement("");51 const frame = requestAnimationFrame(() => {⋯52 setAnnouncement(message);53 });54 return () => {⋯55 cancelAnimationFrame(frame);56 };57 }, [pathname]);5859 return <LiveRegion>{announcement}</LiveRegion>;60}6162export {63 //,64 RouteAnnouncer,65};
Where it goes
Mount it once per document, in the root layout, inside the router context. A live region announces
reliably only when it already exists in the accessibility tree before its text changes, so it must
render from first paint and never unmount. In a React Router app, next to <ScrollRestoration />
works:
DOM position does not matter beyond that: a live region announces on content mutation, not on
document order.
Coexisting with toasts
Toast renders its own polite live region, and the two do not conflict.
Screen readers queue polite announcements across regions, so a toast that fires during a navigation
reads back to back with the route announcement instead of clobbering it.
One caveat: a modal Dialog stamps aria-hidden on the other children
of <body> while it is open, which mutes any live region outside it. A navigation normally closes
the modal first, so this rarely bites, but an announcement raced against a closing modal can go
unread.
Testing it
This is the component's test file, verbatim, and it runs in this repo. The publish lands one frame
after the clearing write, so the flushAnnouncement helper waits out one requestAnimationFrame
inside act before every assertion:
1// @vitest-environment happy-dom2import { act, cleanup, render, screen } from "@testing-library/react";3import { renderToString } from "react-dom/server";4import { createMemoryRouter, MemoryRouter, Outlet, RouterProvider } from "react-router";5import { afterEach, beforeEach, describe, expect, test } from "vitest";6import { RouteAnnouncer } from "./route-announcer";78afterEach(() => {⋯9 cleanup();10});1112/**13 * Renders the announcer in a persistent layout route, the way the app root14 * hosts it, with leaf routes that cover the heading/no-heading branches.15 */16function renderWithRouter(initialPath = "/") {⋯17 const router = createMemoryRouter(18 [⋯19 {⋯20 element: (21 <>⋯22 <RouteAnnouncer />23 <button type="button">persistent control</button>24 <Outlet />25 </>26 ),27 children: [⋯28 { path: "/", element: <h1>Home</h1> },29 { path: "/components", element: <h1>Components</h1> },30 { path: "/no-heading", element: <p>A page without a heading</p> },31 { path: "/components/feedback/alert", element: <h1>Alert</h1> },32 { path: "/components/feedback/toast", element: <h1>Alert</h1> },33 ],34 },35 ],36 { initialEntries: [initialPath] },37 );38 render(<RouterProvider router={router} />);39 return router;40}4142/** Runs the announcer's next-frame publish, which follows the clearing write. */43async function flushAnnouncement() {⋯44 await act(async () => {⋯45 await new Promise<void>((resolve) => {⋯46 requestAnimationFrame(() => {⋯47 resolve();48 });49 });50 });51}5253describe("RouteAnnouncer", () => {⋯54 beforeEach(() => {⋯55 document.title = "";56 });5758 test("server-renders an empty polite live region", () => {⋯59 const html = renderToString(60 <MemoryRouter>⋯61 <RouteAnnouncer />62 </MemoryRouter>,63 );6465 const region = new DOMParser()66 .parseFromString(html, "text/html")67 .querySelector('[role="status"]');68 expect(region).not.toBeNull();69 expect(region?.textContent).toBe("");70 });7172 test("does not announce the initial page load", async () => {⋯73 renderWithRouter();7475 await flushAnnouncement();7677 expect(screen.getByRole("status").textContent).toBe("");78 });7980 test("announces the page heading over a stale document title", async () => {⋯81 const router = renderWithRouter();82 document.title = "Stale title from the previous page";8384 await act(() => router.navigate("/components"));85 await flushAnnouncement();8687 expect(screen.getByRole("status").textContent).toBe("Navigated to Components");88 });8990 test("falls back to the document title when the page has no heading", async () => {⋯91 const router = renderWithRouter();92 document.title = "Kbd — @ngrok/mantle";9394 await act(() => router.navigate("/no-heading"));95 await flushAnnouncement();9697 expect(screen.getByRole("status").textContent).toBe("Navigated to Kbd — @ngrok/mantle");98 });99100 test("falls back to the pathname when there is no heading and no title", async () => {⋯101 const router = renderWithRouter();102103 await act(() => router.navigate("/no-heading"));104 await flushAnnouncement();105106 expect(screen.getByRole("status").textContent).toBe("Navigated to /no-heading");107 });108109 test("stays silent when only the search params or hash change", async () => {⋯110 const router = renderWithRouter();111112 await act(() => router.navigate("/?tab=examples#api-reference"));113 await flushAnnouncement();114115 expect(screen.getByRole("status").textContent).toBe("");116 });117118 test("announces again when the user returns to a previous page", async () => {⋯119 const router = renderWithRouter();120121 await act(() => router.navigate("/components"));122 await flushAnnouncement();123 await act(() => router.navigate("/"));124 await flushAnnouncement();125126 expect(screen.getByRole("status").textContent).toBe("Navigated to Home");127 });128129 test("mutates the region even when two pages share the same heading", async () => {⋯130 const router = renderWithRouter();131132 await act(() => router.navigate("/components/feedback/alert"));133 await flushAnnouncement();134 const region = screen.getByRole("status");135 expect(region.textContent).toBe("Navigated to Alert");136137 // A live region only announces a DOM change. The clear-then-publish138 // sequence must mutate the region even when the text is identical.139 const mutations: MutationRecord[] = [];140 const observer = new MutationObserver((records) => {⋯141 mutations.push(...records);142 });143 observer.observe(region, { characterData: true, childList: true, subtree: true });144145 await act(() => router.navigate("/components/feedback/toast"));146 await flushAnnouncement();147 mutations.push(...observer.takeRecords());148 observer.disconnect();149150 expect(region.textContent).toBe("Navigated to Alert");151 expect(mutations.length).toBeGreaterThan(0);152 });153154 test("does not move focus when it announces", async () => {⋯155 const router = renderWithRouter();156 const control = screen.getByRole("button", { name: "persistent control" });157 control.focus();158159 await act(() => router.navigate("/components"));160 await flushAnnouncement();161162 expect(document.activeElement).toBe(control);163 });164});
Trade-offs
document.querySelector("h1") takes the document's first <h1>. In a one-h1-per-page app
(which yours should be), that is the page heading. A page that renders several gets whichever comes
first in the DOM.
The heading must exist when the effect runs. A page whose <h1> streams in later (deferred
data, a lazy boundary) falls back to document.title for that navigation. If that fallback reads
wrong, move the name into the route's meta export.
English is baked into the prefix. A localized app should pull Navigated to from its i18n
layer along with everything else on the page.