Managing Focus After a Route Change
After reading this you will be able to move focus to the right element after every client-side navigation, without causing a scroll jump, without leaving stray tab stops behind, and with the different behaviour that back and forward navigation deserve.
← Back to Route Change Accessibility
Prerequisites
Core Concept
A real document load resets focus to the top of the document. A client-side navigation does not: focus stays exactly where the user left it, which is on the link they just activated — normally inside the navigation menu at the top of the page.
The consequences are concrete. A keyboard user pressing Tab after navigating continues from the middle of the menu and must traverse the rest of it before reaching the new content, on every navigation. A screen reader user’s virtual cursor stays parked on a link describing content that no longer exists. A screen magnifier user’s viewport stays anchored to the old position. None of it is visible to a mouse user, which is why it survives review.
Moving focus fixes all three at once. The question is only what to focus, and the answer that works best across assistive technology is the new view’s <h1>. It names the page, so focusing it produces a useful announcement; it sits at the top of the content, so Tab continues sensibly; and it is unambiguous, unlike a container whose accessible name is often nothing at all.
Two mechanical details make the difference between a correct implementation and an irritating one. Headings are not focusable, so the target needs tabindex="-1" — programmatically focusable, but not a tab stop. And focus() scrolls its target into view by default, which fights scroll restoration on back navigation; focus({ preventScroll: true }) leaves the scroll decision where it belongs.
Implementation
// TypeScript 5.x — pick a target, focus it, clean up afterwards
const FOCUS_CANDIDATES = ["h1", "[data-route-heading]", "main", "[role='main']"];
export function focusNewView(container: HTMLElement): HTMLElement | null {
const target = FOCUS_CANDIDATES.reduce<HTMLElement | null>(
(found, selector) => found ?? container.querySelector<HTMLElement>(selector),
null,
);
if (!target) return null;
// Headings and landmarks are not focusable; -1 makes them programmatically
// focusable WITHOUT adding a tab stop for everyone else.
const hadTabIndex = target.hasAttribute("tabindex");
if (!hadTabIndex) {
target.setAttribute("tabindex", "-1");
// Remove it again once focus leaves, so the DOM stays honest.
target.addEventListener("blur", () => target.removeAttribute("tabindex"), { once: true });
}
// preventScroll: the router's scroll restoration decides the position, not us.
target.focus({ preventScroll: true });
return target;
}
Timing is the other half. Focusing an element that has not been painted yet is unreliable in some frameworks, and focusing during the render pass forces a synchronous layout:
// TypeScript 5.x — after commit, after paint
export function afterNavigation(container: HTMLElement, restoreScroll: () => void): void {
requestAnimationFrame(() => {
focusNewView(container);
restoreScroll(); // runs after focus, because focus used preventScroll
});
}
Back and forward navigation deserve different treatment from a forward click. A user pressing Back is returning to something they have already seen, and the position they left it in is the meaningful context — moving focus to the top discards that.
// TypeScript 5.x — treat popstate navigations differently from link clicks
type NavigationKind = "push" | "pop";
export function handleNavigation(
kind: NavigationKind,
container: HTMLElement,
savedFocusSelector: string | undefined,
restoreScroll: () => void,
): void {
requestAnimationFrame(() => {
if (kind === "pop" && savedFocusSelector) {
// Returning to a previous view: put the user back where they were.
const previous = container.querySelector<HTMLElement>(savedFocusSelector);
if (previous) {
previous.focus({ preventScroll: true });
restoreScroll();
return;
}
}
focusNewView(container);
restoreScroll();
});
}
/** Record a selector for the focused element before leaving a route. */
export function captureFocusTarget(): string | undefined {
const active = document.activeElement as HTMLElement | null;
if (!active || active === document.body) return undefined;
return active.id ? `#${CSS.escape(active.id)}` : active.dataset.focusKey
? `[data-focus-key="${CSS.escape(active.dataset.focusKey)}"]`
: undefined;
}
Finally, one exception worth honouring: if the navigation changed only part of the screen — a tab within a view, a filter applied in place — moving focus to the page heading is disruptive rather than helpful. Focus the region that changed, or nothing at all, and let the announcement carry the information.
Verification
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("focus moves to the new heading and creates no stray tab stop", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "Billing" }).click();
const focused = await page.evaluate(() => ({
tag: document.activeElement?.tagName,
text: document.activeElement?.textContent?.trim(),
}));
expect(focused.tag).toBe("H1");
expect(focused.text).toBe("Billing");
// The next tab stop must be inside the new content, not back in the menu.
await page.keyboard.press("Tab");
const inContent = await page.evaluate(() =>
document.activeElement?.closest("[data-panel='billing']") !== null,
);
expect(inContent).toBe(true);
// Focusing must not have scrolled the page.
expect(await page.evaluate(() => window.scrollY)).toBe(0);
});
The manual test takes ten seconds and is more convincing than any assertion: put the mouse away, Tab to a navigation link, press Enter, then press Tab once. If the next stop is in the new content, focus management works. If it is the next item in the menu, it does not.
One more check belongs in the suite because it fails silently as an application grows: assert that focus does not land on the same element twice in a row across two different routes. When it does, the usual cause is a shared layout whose heading is being focused instead of the child’s, which means the announcement names the area rather than the page and a screen reader user hears “Settings” on every one of the six screens inside it. A test that records the focused element’s text across a short navigation path catches that immediately, and it is exactly the regression a refactor introduces when someone moves the <h1> up a level to avoid duplicating it.
Gotchas
- Omitting
preventScrollfights scroll restoration. On back navigation the page jumps to the heading and then snaps to the saved offset, producing a visible flash. - A missing
<h1>silently disables the whole thing. Assert one<h1>per route in tests; the focus logic degrades to a container with no accessible name. - Leaving
tabindex="-1"permanently is untidy but harmless; leavingtabindex="0"is a real bug, because it adds a tab stop on a heading. - Focusing during render forces synchronous layout. Schedule it after the commit, in a
requestAnimationFramecallback. - Modal routes need a focus trap as well. Moving focus into the dialog is necessary but not sufficient — it must also be returned to the trigger on close.
- Do not focus on every
replaceState. Filter changes are not navigations; focusing on each one makes a search box unusable.
FAQ
Should I focus the heading or the main landmark? The heading. It has an accessible name, which produces a meaningful announcement, whereas a bare container is often read as “group” or nothing at all.
What if a route has no heading? Give it one — every page needs an <h1> for reasons that go well beyond focus. As an interim measure, focus the main landmark with an aria-labelledby pointing at whatever names the view.
Does moving focus scroll the page? By default yes, which is why preventScroll: true matters. Scroll position should be decided by scroll restoration, not as a side effect of focusing.
Should back navigation focus the heading too? Preferably not. Restoring the element the user was on preserves their context; falling back to the heading when that element no longer exists is the right degradation.
Is focus management enough without announcements? On most desktop combinations it is very effective, but some mobile screen readers report a heading without signalling that a navigation occurred. Do both.
Related
- Route Change Accessibility — the complete sequence a client-side navigation must reproduce.
- Announcing Route Changes with aria-live — the complementary signal, and why neither alone is sufficient.
- Scroll Restoration Strategies — the step that runs immediately after focus and must not fight it.