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.

Where focus lands after a navigation, with and without management Two page diagrams. Without focus management, focus remains on the activated link in the navigation, so the tab sequence continues through the rest of the menu. With focus management, focus is on the new heading, so the next tab stop is the first control in the new content. Unmanaged — focus stranded in the menu Home Billing Reports Account Help next Tab goes here Billing new content, unreachable without tabbing through the rest of the menu Managed — focus on the new heading Home Billing Reports Account Help Billing h1, tabindex="-1" next Tab: first control in the content The fix is one focus() call — the difficulty is entirely in choosing the target and the timing.
Unmanaged focus makes every navigation cost a full traversal of the menu; managed focus makes the next tab stop the first thing on the new page.

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.

Choosing a focus target by navigation kind A decision table. A forward navigation to a new route focuses the new heading. A back or forward history navigation restores the previously focused element. An in-place change such as a tab or filter focuses the changed region or nothing at all. Focus target by navigation kind Forward click a route the user has not seen in this session focus the new h1 preventScroll, tabindex -1 Back / forward returning to a view the user already knows restore prior focus falls back to the h1 In-place change a tab, a filter, a sort — same page, new contents leave focus alone announce instead
One rule does not fit all three: a forward click, a history navigation and an in-place update each want a different focus outcome.

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 preventScroll fights 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; leaving tabindex="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 requestAnimationFrame callback.
  • 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.

Five candidate focus targets and what each announces Five possible focus targets ranked by what a screen reader announces when focus lands on them, from the page heading which names the page down to a bare wrapper element which announces nothing useful. Focus targets ranked by how well they announce what a screen reader says verdict the new <h1> the heading text and its level best — it names the page <main> with aria-labelledby the label, then the landmark good fallback bare <main> "main" or nothing at all weak a wrapper <div> "group", or silence avoid the skip link the link text confusing — implies no navigation
The target's accessible name is the announcement — which is why a heading beats every container.