Web applications need to be accessible. One particular issue is announcing changes. These include notifications, validation errors, and (in this case) navigating between pages.

A traditional server-rendered application will automatically handle that. By using the standard anchor element (such as <a href="/example">Example</a>), navigation will be detected and announced by a screen reader.

However, client-side rendered single-page applications (SPAs) do not benefit from native browser behavior. They don’t cause a full-page reload, so screen readers will be unaware that navigation has occurred — and the user will therefore not be informed that they have moved to a new page.

Frameworks

Looking at other frameworks, Nuxt has a route announcer. So does Next.js. SvelteKit injects a live region to announce the new page.

However, TanStack Router currently does not include a route announcer.

Solutions

Many reference the excellent research by Marcy Sutton on accessible client-side routing techniques.

Shift focus with a custom heading

One proposed solution is to use a custom component for the page heading.

It doesn’t rely on any particular framework and can therefore be used directly. However, it doesn’t support using a custom className attribute, and my headings use Tailwind classes — so I added support for one:

import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";

type PageHeadingProps = React.HTMLAttributes<HTMLHeadingElement> & {
	children: React.ReactNode;
};

export function PageHeading({
	children,
	className,
	...rest
}: PageHeadingProps) {
	const el = useRef<HTMLHeadingElement>(null);

	// When mounted (route has changed), focus the heading
	useEffect(() => {
		el.current?.focus();
	}, []);

	return (
		<h1 tabIndex={-1} ref={el} className={cn("text-2xl", className)} {...rest}>
			{children}
		</h1>
	);
}

The cn utility is included by shadcn/ui. If you don’t already have it, it’s a simple function:

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

That works, but it would mean changing every page to use that custom heading. 🤔

Shift focus with a hook

Another idea: listen for route changes and move focus to the first h1 element found. That avoids needing to add any custom heading component to every page.

Their example uses React Router. It’s simple to adapt for TanStack Router, which has a useLocation hook:

import { useEffect } from "react";
import { useLocation } from "@tanstack/react-router";

export const useLocationFocusHeading = () => {
	const pathname = useLocation({
		select: (location) => location.pathname,
	});

	// When the pathname changes, focus the h1 heading
	useEffect(() => {
		const h1 = document.querySelector("h1");
		const tabIndex = h1?.getAttribute("tabindex");
		h1?.setAttribute("tabindex", tabIndex ?? "-1"); // To focus it programmatically
		h1?.focus();
	}, [pathname]);
};

But what if we want to use the page’s title, or there is no heading? 🤔

Announce the page title

To control exactly what the screen reader announces, we’ll need the route announcer from React Aria:

pnpm add @react-aria/live-announcer

We now need to get the page’s title.

TanStack Router previously didn’t support setting a title. This example route announcer component relies on staticData being added per route:

export const Route = createFileRoute("/about")({
    staticData: {
        title: "About",
    },
}).lazy(() => import("./about.lazy").then((d) => d.Route));

However, TanStack Router now does support setting a page title. That means you can easily set a title without messing with route contexts.

Route announcer

Combining the various solutions above results in this route-announcer.tsx component. This can be modified as needed. For example, you can remove the checks on the path to listen for any change to the URL. You can also choose whether to announce the title or leave it up to the screen reader by moving focus to the heading (you don’t need to tell it to announce that). Or both: announce the title and move focus to the heading. That part is commented out.

If you do uncomment that and want to move focus to the heading, it needs a tabindex set. The code adds that for you. You can also decide whether you want the focus to be visible. It’s a good idea to show which element is focused — but if you don’t want the browser’s native outline, you can add a CSS class to hide it. When using Tailwind, you can hide the browser’s native outline using the outline-none class:

import { useEffect, useRef } from "react";
import { announce } from "@react-aria/live-announcer";
import { useRouter } from "@tanstack/react-router";

export function RouteAnnouncer() {
	const { subscribe } = useRouter();

	// assuming we *only* want to announce when the path changes (not query params)
	// then of course need to note the path
	const previousPathRef = useRef<string | null>(null);

	// subscribe to changes in the location. Announce:
	// - the document title
	// - else the first h1
	// - else the pathname
	// See: https://www.gatsbyjs.com/blog/2019-07-11-user-testing-accessible-client-routing/
	useEffect(() => {
		const unsubscribeFn = subscribe("onResolved", ({ toLocation }) => {
			const currentPath = toLocation.pathname;
			if (previousPathRef.current !== currentPath) {
				// path has changed
				previousPathRef.current = currentPath;

				// bonus: add one frame to allow the DOM to be rendered
				requestAnimationFrame(() => {
					const h1 = document.querySelector("h1");

					// announce the change
					if (document.title) {
						announce(document.title);
					} else {
						const h1Text = h1?.innerText?.trim() ?? h1?.textContent?.trim();
						announce(h1Text || toLocation.pathname);
					}

					/*
					// and/or move focus to the heading since a screen reader
          // will announce that. If want to it needs tabindex set
					// to receive focus programmatically:
					const tabIndex = h1?.getAttribute("tabindex");
					h1?.setAttribute("tabindex", tabIndex ?? "-1");
					// if don't want the browser outline to show that the h1 is focused:
					// https://tailwindcss.com/docs/outline-style#removing-outlines
					h1?.classList.add("outline-none");
					h1?.focus();
          */
				});
			}
		});

		return () => unsubscribeFn();
	}, [subscribe]);

	return null;
}

That is then used in your __root.tsx. Simply adjust the path accordingly:

import { Outlet, createRootRoute, HeadContent } from "@tanstack/react-router";
import { RouteAnnouncer } from "@/components/route-announcer";

export const Route = createRootRoute({
	component: RootRoute,
});

function RootRoute() {
	return (
		<>
			<HeadContent />
			<Outlet />
			<RouteAnnouncer />
		</>
	);
}

On a Mac you can try it using VoiceOver. Hold Command + F5 to enable it. You should be able to navigate by pressing the Tab key to move to a link, pressing Ctrl + Option + Spacebar to click it, and then hear the new page be announced.