Focus management on route change
Single-page apps don't reload — so the browser doesn't move focus for you. You have to.
In the old days of "real" page loads, every link click ripped the page down and built a fresh one. The browser moved focus back to the top automatically, the screen reader started reading the new page, and life was simple.
Single-page apps (SPAs) — apps that swap content without a full reload — broke that. The URL changes, the content changes, but the browser sees no navigation: focus stays where it was. A keyboard user who clicked "Settings" in the nav now has focus stuck inside the nav. A screen reader user hears nothing — no announcement that the page changed.
This lesson is about putting that lost behavior back, by hand. It's a small bit of code that distinguishes accessible apps from ones that fail keyboard users on every navigation.
What the browser does on a real navigation
When a <a href> triggers a full page load, the browser:
- Tears down the old DOM.
- Builds the new one.
- Moves focus to the top of the new page.
- Updates assistive tech: screen readers announce the new title, the new heading, the new landmarks.
Step 3 is what we lose. Steps 4 happens because of step 3 — when focus moves into a freshly-rendered region, screen readers describe that region. No focus move, no announcement.
"The page changed but focus didn't move" is the single most common SPA accessibility bug. If you fix nothing else from this course in your existing app, fix that one.
What an SPA breaks
Imagine a typical Next.js, React Router, or Vue Router app. The user is on / and presses Tab until they reach the "Settings" link in the nav. They activate it. The router replaces the main content with the Settings page.
Now press Tab again. Where does focus go?
It goes to whatever was after the Settings link in the original nav. Because the nav didn't change. From the browser's point of view, you just pressed a button, didn't move, and now you're tabbing through the rest of the nav. The user has to tab all the way out of the nav before reaching anything new on the page.
For a screen-reader user, it's worse: nothing was announced. They have no idea the navigation succeeded.
The standard fix
Pick a target on the new page, give it tabindex="-1", and call .focus() on it after the route renders.
import { useEffect, useRef } from "react";
export function usePageFocus() {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// Move focus to the heading after the new page mounts.
ref.current?.focus();
}, []);
return ref;
}export default function SettingsPage() {
const headingRef = usePageFocus();
return (
<main>
<h1 ref={headingRef} tabIndex={-1}>Settings</h1>
{/* ... */}
</main>
);
}What's happening:
tabIndex={-1}makes the heading programmatically focusable without adding a tab stop. Tab still flows through the page normally; the heading is just reachable by JS.- The effect fires after the new page commits. Focus moves there. The screen reader notices and starts reading from the heading.
- The user's next Tab press now moves into the new page's content, not the leftover nav.
Don't add a visible focus ring around your heading just because you focused it. Use :focus-visible in your CSS — that pseudo-class only paints a ring when the user is actually navigating by keyboard. Programmatic focus + :focus-visible = no spurious ring on click navigation.
In Next.js (App Router), the same pattern works inside app/.../page.tsx. The page is a Server Component, so wrap the focus logic in a small 'use client' component — a <RouteFocusHeading>{title}</RouteFocusHeading> — and let the server pass the title in.
Where to send focus
You have a few options for the target. In rough order of preference:
- The page's
<h1>. This is the most common and usually the best. The heading describes the page, the screen reader reads it, the user gets oriented immediately. - The
<main>landmark. Slightly less informative — the screen reader will announce "main, region" rather than the page title. Use this when the page has no single obvious heading. - A skip link, again. A few apps focus the skip link itself on every navigation. This works but feels like a hack — you've made the user press Tab again to do anything.
- A live region announcement. For routes that don't visually change much (think, applying a filter), an
aria-liveregion saying "Filtered to 12 results" is sometimes better than moving focus. Different tool.
What you should not do is leave focus where it was, or send it to the body, or send it to the document. Those all produce silence and confusion.
Test by tabbing once after each navigation. The next focus stop should be inside the new page's content. If it's still in the old navigation, your route-change focus is broken.
Try it yourself
focus() to the page heading on every route change but a visible focus ring appears even when users click a link. How do you stop the ring on clicks but keep it on keyboard nav?