Testing Focus Management After Client-Side Route Changes
Activating a navigation link in a single-page application deletes the element that was focused and puts nothing in its place. This guide is part of DOM Inspection for Dynamic Content, and it covers the assertions that prove a client-side route change leaves the keyboard in a usable position: that document.activeElement is the new view’s landing target rather than <body>, that the next Tab continues inside the new content instead of restarting at the top of the document, and that the document title changed to describe where the user now is. Together those three checks are what satisfying WCAG 2.2 SC 2.4.3 (Focus Order) looks like in a routed application.
Root Cause
When an element that currently has focus is removed from the document, focus does not transfer anywhere sensible. The browser resets it to the document body, so document.activeElement becomes <body> and there is no longer an element with focus in any meaningful sense. A blur event fires on the node on its way out, carrying no information about where the user should end up. This is the normal outcome of a client-side route change, because the link that was activated usually lives inside a component the router replaces — a breadcrumb, a card, a “view details” button in a list, a tab in a sub-navigation. The user pressed Enter on something and the something ceased to exist.
The consequence is a keyboard state that is worse than merely unhelpful. Sequential focus navigation starts from the position of the current focus, and body has no position, so the next Tab starts from the very beginning of the document. On a page whose header carries a skip link, a logo, a search field and a thirty-item primary navigation, the user who asked to see the reports view now needs thirty-four Tab presses to reach any of it — every single time they navigate. Shift-Tab is no better; from body it wraps to the browser’s own chrome. Nothing on screen indicates why, because the visual result of the navigation looks completely correct.
Screen-reader users get even less than that. A full page load makes assistive technology announce the new document title, which is how a user knows the navigation succeeded. A client-side route change produces no load event, and updating document.title from JavaScript is not reliably announced, so the sequence is: activate a link, hear nothing, find that the virtual cursor is at the top of an unchanged-sounding document. Moving focus deliberately is what restores the missing announcement — focusing the new view’s heading causes the accessible name, role and level of that heading to be announced, which tells the user both that something happened and what they arrived at.
None of this is visible to a scanner, and that is worth being precise about, because the fix is often mistaken for a timing problem. Both DOM snapshots are valid: before the click the old view is well-formed, after the transition the new view is well-formed. There is no element to point at and no rule to fail. What is wrong is a property of the transition itself, which means it can only be caught by a test that performs the navigation and then asks a question about the resulting focus. Scan timing across the same transition is a genuinely separate concern with a separate answer — see waiting for route transitions before an axe scan for that — and confusing the two leads to teams adding waits to a suite that has no focus assertions in it at all.
Configuration
The application side is a landing target plus a title update, applied after the new view is in the DOM. Headings are not focusable, so the target needs tabindex="-1", which makes it focusable by script without adding it to the tab order. Set the title before moving focus, so that a screen reader processing the focus change is already looking at the new document name.
// src/router/land-on-view.js — called once per completed client-side navigation
export function landOnView({ title }) {
document.title = title; // set first: the focus move is what gets announced
// The heading is the preferred target because its name, role and level are
// announced. The wrapper is the fallback for views that render it async.
const target =
document.querySelector('[data-view-root] h1') ??
document.querySelector('[data-view-root]');
if (!target) return;
// tabindex="-1" is script-focusable and stays out of the tab order.
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
// Do not steal focus if the user has already moved it into the new view,
// which happens when a transition is slow and the user keeps typing.
const active = document.activeElement;
if (active && active !== document.body && target.contains(active)) return;
target.focus();
}
The test drives a real navigation and then makes three separate assertions. Keep them separate: they fail for different reasons and a combined boolean tells nobody anything.
// tests/a11y/route-focus.spec.ts
import { test, expect, type Page } from '@playwright/test';
// A description of focus that is useful in a failure message.
async function describeActive(page: Page) {
return page.evaluate(() => {
const el = document.activeElement as HTMLElement | null;
if (!el || el === document.body) {
return { tag: 'body', name: '', inView: false, inHeader: false };
}
return {
tag: el.tagName.toLowerCase(),
name: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 60),
inView: !!el.closest('[data-view-root]'),
inHeader: !!el.closest('header'),
};
});
}
const ROUTES = [
{ link: 'Reports', heading: /quarterly reports/i, title: /Reports/ },
{ link: 'Settings', heading: /notification settings/i, title: /Settings/ },
];
for (const route of ROUTES) {
test(`route to ${route.link} lands focus in the new view`, async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: route.link }).click();
// Wait for the arrival, not for a duration: the heading only exists after
// the router has committed the new view.
await page.getByRole('heading', { level: 1, name: route.heading }).waitFor();
// 1. Focus is in the new view, not on body and not left in the header.
const active = await describeActive(page);
expect(active, 'focus fell to body after the route change').not.toMatchObject({
tag: 'body',
});
expect(active.inView, `focus landed on <${active.tag}> "${active.name}"`).toBe(true);
// 2. The document title describes the new location.
await expect(page).toHaveTitle(route.title);
// 3. The next Tab continues inside the view instead of restarting at the
// top of the document. This is the assertion that proves the user is
// not being sent back through the header on every navigation.
await page.keyboard.press('Tab');
const afterTab = await describeActive(page);
expect(
afterTab.inHeader,
`Tab after navigation went back to the header: "${afterTab.name}"`,
).toBe(false);
expect(afterTab.inView).toBe(true);
});
}
The inHeader assertion is the one that earns its place. A view that moves focus to a hidden element, or to a wrapper positioned before the header in DOM order, satisfies “focus is not on body” while still sending the next Tab press through the entire global navigation. Only pressing the key proves the ordering.
Validation
Prove each assertion fails for its own reason. Remove the landOnView call, then restore it and remove only the title update, then restore that and move focus to a wrapper rendered above the header:
npx playwright test tests/a11y/route-focus.spec.ts --reporter=list
# landOnView() removed entirely:
# ✘ route to Reports lands focus in the new view
# focus fell to body after the route change
# Expected: not objectContaining {"tag": "body"} Received: {"tag": "body"}
#
# Focus moved, document.title left as the old view's name:
# ✘ route to Settings lands focus in the new view
# expect(page).toHaveTitle(/Settings/)
# Expected pattern: /Settings/ Received string: "Reports — Acme"
#
# Focus moved to a wrapper that precedes <header> in DOM order:
# ✘ route to Reports lands focus in the new view
# Tab after navigation went back to the header: "Skip to content"
# Expected: false Received: true
#
# Correct implementation:
# ✓ route to Reports lands focus in the new view
# ✓ route to Settings lands focus in the new view
Add one more validation pass that nobody remembers to run: navigate away and back, twice. A → B → A exercises the teardown path, and a router that focuses correctly on first arrival often fails on the return because the landing target is being reused from a cached view that still carries focus state from last time. If the assertions hold across four navigations they will hold in production.
Edge Cases and Conditional Guards
- Back and forward navigation. A history pop is a route change with a stronger expectation: the user is returning to something they have already seen, so restoring focus to the element they left is better than focusing the heading, and focusing the heading is far better than nothing. Add a leg that calls
page.goBack()and re-runs all three assertions, because a router that hooks only its own link handler will manage focus on clicks and do nothing at all on the browser’s own back button. - Dialog and drawer routes. When a route renders a modal over the previous view, the correct landing target is inside the dialog, not the view heading, and the previous view remains in the DOM behind it. Branch the assertion on
role="dialog"containing the active element, and add the reverse expectation for closing: dismissing the dialog route must return focus to the control that opened it, or the user is dropped back onto body by the same mechanism this whole guide is about. - A heading that does not exist yet. Views that fetch before rendering their title have no
h1at the moment the router completes, solandOnViewfocuses nothing and the test’swaitForis doing all the work of hiding the bug. Focus the stable view wrapper in that case and let the heading arrive later, and be careful that the test’s arrival signal is the view, not the heading, or the assertion passes for the wrong reason. A live region announcing the load is the complement to this, which is where detecting detached ARIA live regions in SPA navigation becomes relevant.
Pipeline Impact
This is a behavioural spec, so it gates on the runner’s exit code and shows up as a failed test rather than as a violation in a report. That means violation-count thresholds cannot express it and a job configured to fail only above N violations will never block on it — the spec has to sit in a job that is itself a required check, which is a branch-protection decision rather than a scanner configuration, covered in requiring accessibility status checks in branch protection.
Cost is low and predictable: one navigation, one keypress and three assertions per route, which is roughly 400 milliseconds of runner time per route on top of the page load. That makes it cheap enough to run over every entry in the route table rather than a sample, and running it over all of them matters because focus management is usually implemented per view rather than centrally, so coverage of /reports says nothing about /settings.
Make failures readable without a reproduction. Enable trace: 'retain-on-failure' so the trace viewer shows the DOM at the moment of the failed assertion, and keep the describeActive payload in the assertion message — a failure that says focus landed on <a> "Skip to content" needs no investigation, while expected true, received false needs a developer to rebuild the scenario locally. Broader keyboard-order coverage for a single view, as opposed to across a transition, belongs in the guide on testing keyboard focus order with Playwright.
Common Pitfalls
- Calling
focus()on a heading with notabindex="-1", which silently does nothing and leavesdocument.activeElementas body while the code looks correct. - Giving the landing target
tabindex="0"instead of-1, which fixes the focus move and adds a permanent stop in the tab order that no user expects. - Asserting on a visible focus indicator instead of
document.activeElement, so a target styled withoutline: nonereports a pass while the user sees nothing. - Accepting focus on body as “reset to the top of the page”, which is a description of the bug rather than of any behaviour a user benefits from.
- Moving focus but leaving
document.titleon the previous view, so browser history, tab titles and bookmark names all describe the wrong page. - Running the assertion only on a first navigation, missing the return-visit case where a cached view is reused and the focus effect never fires again.
FAQ
Should focus go to the heading, the main landmark, or a skip link?
The heading is the best default because focusing it causes its accessible name and level to be announced, which tells the user both that navigation happened and where they are. The main landmark is an acceptable alternative when a view has no single heading, though announcements are less specific. A skip-target element with visually hidden text is the pragmatic choice for views whose heading arrives asynchronously, since it can carry a stable name like “Reports view, loaded” without waiting on data. What matters more than the choice is that the target sits after the header in DOM order, so the next Tab moves forward rather than back.
Does updating document.title announce the new page on its own?
Not dependably, and not in a way worth designing around. Assistive technology announces the document title on a real page load; a scripted title change in a routed application may be announced, delayed, or ignored depending on the screen reader and browser pairing. Update the title anyway — it is what the browser tab, the history entry and the bookmark name all use, and it is trivially assertable — but treat the focus move as the mechanism that actually informs the user, and the title as metadata that has to agree with it.
Is a lost focus position a WCAG failure or just poor practice? It is normally assessed against SC 2.4.3 (Focus Order), because the focus sequence after the navigation no longer preserves meaning and operability: the user is placed at a point in the document that has no relationship to the action they performed. Whether an individual instance is judged a formal failure or a usability defect depends on the reviewer and the severity of the resulting sequence, which is a good reason to write the assertion rather than argue about the classification. In practice, a keyboard user who needs thirty-four Tab presses after every navigation has an operability problem regardless of which criterion is cited.
Related
- DOM Inspection for Dynamic Content — the parent guide on settle signals and scanning each interaction state.
- Handling Dynamic ARIA States in Modern JavaScript Frameworks — the sibling assertion for state attributes that must track a render cycle.
- Custom Rule Development & Context-Aware Testing — the section covering context-aware checks and how they reach a CI gate.