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-busy views need a settle step before the focus check.
Focus state transitions across a client-side route change Focus starts on the clicked link; the bad path leaves focus stranded on the link, the good path moves focus to the new view heading or main. click nav link focus on link no focus move stranded — FAIL focus moved h1 / main — PASS SC 2.4.3 focus order met
A passing route change moves focus to the new view's heading or main region, satisfying SC 2.4.3.

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-busy views: a view that fetches data may render the heading before content is ready. Poll until main[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 a role="dialog" that contains document.activeElement also 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 body as acceptable; a screen reader announces nothing useful from body, so it is a failure.
  • Hard-coding a waitForTimeout; wait on the resolved heading or aria-busy instead 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.