How to Test Focus Order, Skip Links, and Landmark Structure in Single-Page Apps Without Relying on Manual Screenreader Passes
By Luca Müller · September 4, 2026
A practical guide to testing focus order, skip links, and landmark structure in SPAs with automated checks, concrete assertions, and clear boundaries for manual review.
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:
- Keyboard focus order: pressing Tab and Shift+Tab should move focus through interactive content in a predictable order.
- Skip link behavior: the first interactive control should usually let keyboard users jump past repeated chrome to the main content.
- 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.
How to test skip links without making the test fragile
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:
- the link is first in the tab order, or at least first among interactive controls in the app shell
- the href target exists on the current route
- 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
mainlandmark - one primary
navlandmark if navigation is present - a stable
headerandfooterif 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:
- user activates a navigation link
- route changes
- focus moves to the new page heading or main landmark
- 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 focusabledisplay: none,visibility: hidden, orinert- 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
#mainto resolve incorrectly - scroll restoration logic overriding focus movement
If landmark counts are wrong
Check for:
- nested or repeated
mainregions - 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:
- skip link activates on the app shell
- the main landmark exists once on each route template
- 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.
Should the skip link focus the main element or the first heading?
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.