Testing Focus Management After Client-Side Route Changes
When a single-page app swaps views without a full page load, the browser does nothing to move focus — it stays on the link the user just clicked, or worse, resets to <body>. Keyboard and screen-reader users are stranded with no signal that the page changed. This guide is part of DOM Inspection for Dynamic Content, and it shows how to assert that focus lands on a sensible target — the new page’s heading or main region — after a client-side route change, satisfying WCAG 2.2 SC 2.4.3 (Focus Order), with a Playwright test that inspects document.activeElement and a reusable check you can fold into your custom rule set.
Key implementation targets:
- After navigation, focus must move to the new view’s heading or
main, not linger on the trigger. - The assertion reads
document.activeElement, not a visual cue, because that is what assistive tech tracks. - Async data and
aria-busyviews need a settle step before the focus check.
Root Cause / Context
A real navigation triggers the browser to reset focus and the screen reader to announce the new page title. A client-side route change is just a DOM swap — the URL updates via the History API, but focus does not move. Default accessibility scanners cannot catch this because the violation is temporal: the rendered DOM at any instant may be perfectly valid; what is wrong is the transition. axe-core scans a static snapshot and has no notion of “before and after a route change,” so a focus-management gap passes every snapshot scan while still failing real users.
Catching it requires a runner that performs the navigation and then inspects where focus landed. Playwright is well suited because it can click the link, wait for the new view, and evaluate document.activeElement in the page context. The check is straightforward — is the active element the new heading, inside main, or a container the app explicitly moved focus to — but it must run after the view settles, which is where aria-busy and async data complicate things.
Configuration
The app’s correct behavior is to focus the new view’s heading (or a wrapper with tabindex="-1") after the route resolves. A minimal correct router effect looks like this:
// router-focus.js — call this after each client-side navigation completes
export function focusNewView() {
// Prefer the main landmark's heading; fall back to the main region itself.
const target =
document.querySelector('main h1') ?? document.querySelector('main');
if (!target) return;
// Headings are not focusable by default; make the target programmatically focusable.
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
target.focus();
}
The Playwright test drives a real navigation, waits for the new view, and asserts the active element is sensible. The assertion runs in the page so it reads the live document.activeElement:
// tests/focus-after-route.spec.ts
import { test, expect } from '@playwright/test';
// A focus target is "sensible" if it is an h1, or lives inside <main>.
async function activeElementIsSensible(page) {
return page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return false; // body == focus was lost
const main = document.querySelector('main');
const isHeading = el.tagName === 'H1';
const insideMain = !!main && main.contains(el);
return isHeading || insideMain;
});
}
test('focus moves to the new view after route change (SC 2.4.3)', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Reports' }).click(); // client-side nav
// Wait for the new view to be ready before checking focus.
await page.waitForURL('**/reports');
await page.getByRole('heading', { level: 1, name: /reports/i }).waitFor();
await expect.poll(() => page.evaluate(() =>
document.querySelector('main')?.getAttribute('aria-busy'))).not.toBe('true');
expect(await activeElementIsSensible(page)).toBe(true);
});
For broader coverage, fold the same logic into an axe-style custom check you run after navigation. axe cannot trigger the navigation, but it can evaluate the post-navigation focus state when you call it at the right moment:
// focus-check.js — register a custom check axe runs against the document
axe.configure({
checks: [{
id: 'view-focus-managed',
evaluate: function () {
const el = document.activeElement;
if (!el || el === document.body) return false;
const main = document.querySelector('main');
return el.tagName === 'H1' || (!!main && main.contains(el));
},
}],
rules: [{
id: 'route-change-focus',
selector: 'main', // run once per main landmark
tags: ['wcag2a', 'wcag243'], // WCAG 2.2 SC 2.4.3 Focus Order
impact: 'serious',
all: ['view-focus-managed'],
metadata: { description: 'Focus must move into the new view after navigation' },
}],
});
Validation
Run the Playwright test against both the broken and fixed router to prove it catches the regression. Comment out the focusNewView() call and the test should fail with focus on body:
npx playwright test tests/focus-after-route.spec.ts --reporter=list
# With focus management OFF:
# ✘ focus moves to the new view after route change (SC 2.4.3)
# expect(received).toBe(expected) Expected: true Received: false
# With focus management ON:
# ✓ focus moves to the new view after route change (SC 2.4.3)
To validate the axe check, run axe.run() immediately after a navigation in the same test context and confirm route-change-focus appears in passes when focus is managed and in violations when it is not.
Edge Cases & Conditional Guards
aria-busyviews: a view that fetches data may render the heading before content is ready. Poll untilmain[aria-busy]is not"true"before asserting focus, or you will check focus mid-load and get a flaky pass.- Async data: if the heading text depends on fetched data, wait on the resolved heading (
getByRole('heading', { name: /.../ })) rather than a fixed timeout, so the focus check runs only after the real target exists. - Modal routes: when a route opens a dialog, the sensible target is the dialog, not
main. Branch the check so arole="dialog"that containsdocument.activeElementalso passes — focus inside the modal is correct, focus left behind on the page is not.
Pipeline Impact
This is a behavioral test, so it gates through Playwright’s exit code like any functional assertion: a stranded-focus regression fails the job and blocks the PR when the check is required. Because the violation is invisible to snapshot scanners, this test is the only thing standing between a refactor of the router and a silent WCAG 2.2 SC 2.4.3 regression. Keep it in the same suite as your other route tests so it runs on every navigation change, and upload the Playwright trace as an artifact so a failure shows exactly where focus landed.
Common Pitfalls
- Asserting on a visible focus ring instead of
document.activeElement; the ring can be suppressed while focus is still wrong. - Checking focus before the new view settles, producing a flaky pass when the old heading is briefly still in the DOM.
- Treating focus on
bodyas acceptable; a screen reader announces nothing useful frombody, so it is a failure. - Hard-coding a
waitForTimeout; wait on the resolved heading oraria-busyinstead so the test is deterministic. - Forgetting modal routes, where focus correctly belongs in the dialog rather than
main.
FAQ
Why not just move focus to the top of the page?
Blindly focusing body or the document root announces nothing and forces the user to tab from the very top on every navigation. Focusing the new view’s <h1> (made focusable with tabindex="-1") both announces the page identity and starts the user at the content, which is what SC 2.4.3 expects in spirit.
Can axe-core detect this on its own? No. axe scans a static DOM snapshot and has no concept of a navigation transition. You must drive the route change in a runner like Playwright and then evaluate the focus state; the custom check shown here only formalizes the assertion so it reads like the rest of your rule set.
What about back/forward navigation?
Apply the same expectation: a History pop that swaps the view should also move focus to the restored view’s heading. Add a test that uses page.goBack() and re-runs the same activeElementIsSensible assertion to cover it.
Related
- DOM Inspection for Dynamic Content — the parent section on testing dynamic DOM behavior.
- Handling Dynamic ARIA States in Modern JavaScript Frameworks — sibling guide on asserting ARIA state changes.
- Handling Single-Page Application Routing — broader SPA navigation testing this focus check belongs to.