Add a route announcer to TanStack Router
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.