Add a route announcer to TanStack Router
Web applications need to be accessible.
A traditional server-rendered application will automatically handle announcing route changes. By using the standard anchor element (<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. They don’t cause a full-page reload and so screen readers will be unaware that navigation has occurred. 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?
We’ll start with the excellent research by Marcy Sutton on accessible client-side routing techniques.
Shift focus with a custom heading?
One potential solution would be 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. 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 imporrted 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 component. Not ideal. 🤔
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 add the route announcer from React Aria:
pnpm add @react-aria/live-announcerWe 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 set a title without messing with route contexts.
So …
Route announcer component
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 *only* want to announce when the path changes (not query params) // then only need the path const previousPathRef = useRef<string | null>(null);
// subscribe to changes in the location // 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;
// add one frame to allow the DOM to be rendered requestAnimationFrame(() => { const h1 = document.querySelector("h1");
// announce the change // 1. the document title // 2. else the first h1 // 3. else the pathname if (document.title) { announce(document.title); } else { const h1Text = h1?.innerText?.trim() ?? h1?.textContent?.trim(); announce(h1Text || toLocation.pathname); }
/* // uncomment to move focus to the heading if want screen reader // to announce that. To do that, 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:
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.