Screen readers are not the only way to catch accessibility regressions in a single-page app. If you can reliably tab through the app, verify that focus moves in a sensible order, confirm skip links work after route changes, and assert that landmark structure stays stable, you can catch a large class of failures before a manual screenreader pass ever starts.

That does not replace human accessibility review. It does reduce how often human review has to act as a fire alarm.

For SPAs, the problem is not just whether elements are reachable. It is whether route transitions preserve keyboard expectations, whether the app returns focus to a meaningful place, and whether landmark regions still make sense after client-side navigation. Those are testable behaviors, and they are worth testing in code.

The three checks that matter most

If your goal is to test focus order in single-page apps, start with these three checks:

  1. Keyboard focus order: pressing Tab and Shift+Tab should move focus through interactive content in a predictable order.
  2. Skip link behavior: the first interactive control should usually let keyboard users jump past repeated chrome to the main content.
  3. Landmark structure: each route should expose a clear set of landmarks, especially main, nav, header, footer, and any complementary or dialog regions that are visible.

These map directly to WCAG expectations around keyboard access, focus order, and structure. The relevant success criteria are not obscure, but the implementation details vary by app. See the WCAG overview for the standard itself.

The hard part in SPAs is not knowing that keyboard access matters. It is proving that routing, lazy rendering, overlays, and animation did not silently break it.

Define the test surface before you automate

Before you write a single assertion, decide what you are testing.

Test the route shell, not every pixel

For focus and landmark checks, the important surface is the app shell after a route transition:

  • persistent navigation
  • skip link target
  • page title or heading update
  • primary content landmark
  • modal or drawer overlays that can trap focus

Do not try to validate every visual detail with keyboard automation. That creates brittle tests with little accessibility value.

Separate order from visibility

A control can be visible but unreachable by keyboard, or reachable but hidden behind an overlay. Your automation should assert both:

  • the tab sequence reaches the element
  • the element is actually usable when focused

Separate page focus from browser focus

In SPAs, route changes often replace the main content without a full document reload. Your test needs to know whether focus remains on the old element, falls back to the body, or moves to a route heading. Those are different failures, and they point to different fixes.

A practical assertion model

For each route, check the following in order:

  • the first Tab lands on the expected skip link or first interactive element
  • activating the skip link moves focus to the main content target
  • the main landmark exists exactly once on the page
  • the visible route heading or main heading receives focus when intended, or the focus target is otherwise announced in a stable, documented way
  • tabbing does not enter hidden menus, offcanvas panels, or inert content

That last point is important. In SPAs, stale DOM nodes often stay mounted but hidden. If they remain tabbable, keyboard users can get lost in invisible controls.

Example: keyboard navigation smoke test in Playwright

The following example is intentionally small. It verifies that a skip link is present, can be activated, and sends focus to the main content after navigation.

import { test, expect } from '@playwright/test';
test('skip link and focus order on route entry', async ({ page }) => {
  await page.goto('https://example.test/app');

  await page.keyboard.press('Tab');
  await expect(page.locator('a[href="#main"]')).toBeFocused();

  await page.keyboard.press('Enter');
  await expect(page.locator('#main')).toBeFocused();

  const landmarks = page.locator('main');
  await expect(landmarks).toHaveCount(1);
});

This is not a full accessibility test. It is a focused regression check. The value comes from making the expected focus behavior explicit.

Why this style of assertion works

  • It tests actual keyboard behavior, not just DOM presence.
  • It fails when the skip link is missing, hidden, broken, or targets the wrong element.
  • It catches route-level regressions where a framework update changes focus handling.

What this does not prove

  • It does not verify that the focused element has a visible focus ring.
  • It does not guarantee a screen reader will announce the route change correctly.
  • It does not prove that all interactive controls are reachable in the intended order.

Those gaps are where manual review still matters.

Skip links are simple in HTML, but SPAs make them fragile because route transitions, hydration, and shell reuse can interfere with focus target behavior.

A skip link test should verify three things:

  1. the link is first in the tab order, or at least first among interactive controls in the app shell
  2. the href target exists on the current route
  3. activating it moves focus to a meaningful region, usually main

A good skip-link target should be a programmatic focus target, not just a visual anchor. If your target is a main element, make sure the app deliberately focuses it when skipped to, or that the target contains a heading and supports focus in a documented way.

Common failure modes

  • the skip link exists but is visually hidden and not revealed on focus
  • the href points to an ID that only exists on some routes
  • the target is inside a component that mounts after a delay
  • route transitions leave focus on the old link instead of the destination
  • CSS transforms or fixed headers obscure the target after focus moves

A simple DOM assertion is not enough here. You need to verify the actual focus state after activation.

Landmark structure testing: what to check and what not to overcheck

Landmarks are useful because they let users jump quickly to major regions of the page. For automated checks, you do not need to model every screen reader shortcut. You only need to confirm that the app exposes a stable, navigable structure.

Minimal landmark assertions

On each route, check for:

  • exactly one main landmark
  • one primary nav landmark if navigation is present
  • a stable header and footer if the app shell uses them
  • no duplicate landmark names that would create ambiguity

If your app uses multiple nav regions, label them clearly with aria-label or aria-labelledby so automation can distinguish them.

Example DOM assertions

import { test, expect } from '@playwright/test';
test('landmarks are present and unique', async ({ page }) => {
  await page.goto('https://example.test/app/dashboard');

  await expect(page.locator('main')).toHaveCount(1);
  await expect(page.locator('nav')).toHaveCount(1);
  await expect(page.locator('header')).toHaveCount(1);
});

That check is intentionally conservative. If your app has multiple nav regions, replace the raw selector count with an accessibility tree assertion or with role-based locators and labels.

Avoid overfitting to the DOM

Do not assert that the landmark structure matches a fixed component tree if the route content is intentionally modular. What matters is the user-facing structure, not whether the route was assembled from three React components or eight.

Focus order testing for SPA route transitions

Route transitions are where many regressions appear. A click on a client-side navigation link may update content without moving focus, which leaves keyboard users stuck in the old context.

A robust route-transition test checks the following:

  • the route changes without a full reload, if that is the app design
  • focus lands on the route heading, page title target, or main container as documented by the team
  • the first few Tab presses after navigation go to controls in the expected visual order

A route focus pattern worth testing

A common and maintainable pattern is:

  1. user activates a navigation link
  2. route changes
  3. focus moves to the new page heading or main landmark
  4. tab order resumes from the top of the new content

If your app uses this pattern, test it directly.

import { test, expect } from '@playwright/test';
test('route change moves focus to the new heading', async ({ page }) => {
  await page.goto('https://example.test/app');

  await page.getByRole('link', { name: 'Reports' }).click();
  await expect(page.getByRole('heading', { name: 'Reports' })).toBeFocused();
});

If your product intentionally uses a different pattern, such as restoring focus to the clicked control in some flows, document that rule and test for it explicitly. Ambiguous focus behavior is where test suites become noisy.

Where automated checks stop

Automation can validate structure and keyboard behavior. It cannot fully replace a human review of the accessibility experience.

Use manual screenreader passes for:

  • whether route changes are announced clearly enough
  • whether headings sound meaningful out of context
  • whether focus order matches a user’s mental model on complex forms
  • whether virtualized lists, comboboxes, or drag-and-drop widgets are understandable
  • whether error messages are announced at the right time and with the right priority

If a component depends on subtle speech output, automation can confirm the plumbing, but not the quality of the announcement.

This is the boundary that matters. Automated checks should catch the regressions that are easy to miss and expensive to repeat. Manual review should focus on the interactions that require judgment.

Debugging failures efficiently

When a focus test fails, do not start by staring at the UI. Start with the failure class.

If Tab skips the expected control

Check for:

  • tabindex="-1" where it should be focusable
  • display: none, visibility: hidden, or inert
  • overlay or modal layers intercepting focus
  • disabled state applied unintentionally

If focus lands on the wrong element after navigation

Check for:

  • client-side route code not calling the intended focus target
  • the target element mounting too late
  • a duplicate ID causing #main to resolve incorrectly
  • scroll restoration logic overriding focus movement

If landmark counts are wrong

Check for:

  • nested or repeated main regions
  • route layouts that leave the previous page mounted
  • hidden duplicate navigation regions
  • empty wrappers that were given landmark roles without a user benefit

These are not cosmetic bugs. They affect how keyboard and assistive technology users move through the app.

A small checklist for CI

If you want this to survive CI and framework upgrades, keep the suite small and route-focused.

  • one smoke test for the app shell
  • one smoke test for each major route template
  • one skip-link test per layout variant
  • one landmark uniqueness check per route template
  • one route-transition focus assertion for key user flows

Do not run the full keyboard matrix on every build unless your app is tiny. Focus behavior is important, but a bloated suite becomes maintenance debt.

A simple decision rule

  • Automate the checks that are deterministic and repeatable.
  • Review manually the behaviors that depend on reading order, announcement quality, or dynamic widget semantics.
  • Escalate to a full accessibility audit when a route or component introduces a new interaction model.

When framework choice affects the test design

The framework is less important than the assertions, but it still changes what is easy to maintain.

  • Playwright is a strong fit when you want direct keyboard control, role-based locators, and stable route-level smoke tests.
  • Cypress can work well for app-shell checks, especially if your team already owns Cypress and wants to keep one browser toolchain.
  • Browser cloud services are useful when you need broad environment coverage, but they do not change the accessibility logic you need to assert.
  • No-code and AI-assisted tools can help teams record flows faster, but the most durable accessibility checks still depend on clear expectations for focus and landmarks rather than visual replay alone.

The point is not to chase the tool with the longest feature list. The point is to encode the smallest set of checks that fail when keyboard access regresses.

A practical recommendation

If you are starting from zero, build three tests first:

  1. skip link activates on the app shell
  2. the main landmark exists once on each route template
  3. route transitions move focus to a documented destination

Those three checks will catch more real regressions than a large pile of fragile, low-signal assertions.

Then add manual screenreader review only where the automation reaches its limit, especially for complex widgets and announcement quality.

That combination is usually enough to keep an SPA keyboard-accessible without turning every release into a full manual accessibility sweep.

FAQ

Can I test focus order only by checking tabIndex values?

No. tabIndex values can be misleading. The actual tab order depends on DOM order, browser defaults, disabled state, hidden state, and modal behavior. Always verify real keyboard traversal.

Either can work if the team documents it and the behavior is consistent. The important part is that the destination is meaningful and focus actually lands there after activation.

How many landmarks should a route have?

Usually one main, one or more labeled nav regions if needed, and the shell landmarks that make sense for the layout. Avoid duplicate unlabeled landmarks that create ambiguity.

Why do SPA route changes fail accessibility tests so often?

Because the DOM updates without a full page reload, focus is not automatically reset, and hidden shell elements can stay mounted. That creates keyboard traps, stale focus, or duplicate landmarks.

Do I still need manual screenreader testing if these automated checks pass?

Yes. Automated checks can verify structure and focus behavior, but they cannot fully judge announcement quality, reading order in complex flows, or the usability of advanced widgets.