function PageHeading({ title }: { title: string }) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
document.title = title + " | Shop";
ref.current?.focus();
}, [title]);
return <h1 ref={ref} tabIndex={-1}>{title}</h1>;
}
In a normal website, clicking a link loads a new page. The browser resets focus to the top, and the screen reader announces the new page title. A single-page app (SPA) only swaps part of the DOM with JavaScript. So none of that happens.
What goes wrong:
- Silence: the screen reader says nothing. The user doesn't know the page changed.
- Lost focus: focus stays on the link that was clicked. If that link was removed, focus falls back to
<body>. The next Tab starts from a strange place. - Stale title:
document.titlemay not update, so the tab and history show the old page.
Fixes, usually together:
- Update
document.titleon every route change. - Move focus to the new page's
<h1>, or a wrapper around the main content, withtabindex="-1"so it can take focus from script. The screen reader reads the heading. - Or announce the change in a polite live region, such as "Order history, page loaded".
Some frameworks help. Next.js has a built-in route announcer. It reads document.title, then the <h1>, then the URL. But you still choose where focus goes. Test it with a screen reader, because teams prefer different patterns.
- A client-side route change does not reset focus or announce the new page.
- Focus can stay on a removed link and fall back to the body.
- Update
document.titleon every route change. - Move focus to the new
<h1>or main wrapper withtabindex="-1", or announce the change in a live region. - Frameworks like Next.js include a route announcer, but you still need to test.