Add a route announcer to TanStack Router

·5 min read

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:

Terminal window
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 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:

route-announcer.tsx
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.

#spa#accessibility#reader
Back to posts