How-To · Checklist · SPA

The Focus Management Checklist for Single-Page Apps

An oxblood and cream editorial illustration of a cursor and a focus ring drifting apart from a highlighted page heading.
  • How-To
  • Checklist
  • SPA

Picture the exact moment. A screen reader user is browsing a React or Vue app. They tab to a link, press Enter, and the URL changes. New content renders on the screen. Everything looks fine to a sighted developer glancing at the browser.

But the screen reader says nothing at all.

No new heading is announced. No "navigated to" cue. Nothing. Because a client-side route change is invisible to assistive technology unless the app explicitly tells it otherwise. A traditional page load fires a full reload, the browser resets focus to the document body, and the screen reader announces the new page title automatically. A single-page app does none of that by default. It just swaps some DOM nodes and leaves the user's screen reader cursor sitting on a link that, for all the assistive technology knows, still exists.

The Stat: WCAG 2.4.3 (Focus Order) and 4.1.3 (Status Messages) are both Level A/AA success criteria that a client-side route change can silently violate if focus and announcement are not handled manually. (Source: W3C WCAG 2.2)

SPA route change focus flow A flow diagram beginning with User clicks link, moving to URL changes and DOM swaps with no real page load, then splitting into two outcomes: a broken branch where focus stays on the old, now-removed element, marked with a warning icon, and a correct branch where focus moves to the new heading and an aria-live region announces the route change, marked with a checkmark. User clicks link URL changes, DOM swaps (no real page load) ! Focus stays on old, now-removed element (broken) Focus moves to new heading + aria-live announces route

Why this is a distinct problem from basic keyboard navigation

If you have already worked through visible focus indicators and logical tab order, you have handled the baseline. That work matters, and it is worth reading our piece on keyboard navigation and visible focus if you have not yet. But SPA focus management is a different animal. It is not about whether focus is visible. It is about whether focus, and the announcement that goes with it, moves at all when the "page" changes without an actual page load.

This checklist covers the four places SPA architecture breaks focus in ways a static site never would: route changes, live region announcements, modal focus trapping, and focus restoration after a modal closes.

1. Move focus to the new view's heading on every route change

When a route changes, the very first thing your router's navigation handler should do is find the new view's main heading and move focus to it programmatically.

The pattern:

  • Give the top-level heading of each route (usually an h1) a tabindex="-1" so it can receive focus without being part of the normal tab order.
  • In your router's "on navigate complete" hook, call .focus() on that heading.
  • Do this after the new DOM has actually rendered, not before, or you will focus an element that is not there yet.

This single change fixes the majority of the silence problem, because most screen readers will announce the accessible name of an element the moment it receives focus. Moving focus to the heading effectively re-creates the "new page landed" cue that a full page load gives you for free.

Common mistake: teams focus the router's outer <div> instead of the heading. That div usually has no accessible name, so the screen reader announces nothing, or announces something generic and unhelpful like "region." Always target the heading itself.

2. Announce the route change with a live region, not just focus

Moving focus helps, but it is not the whole story, especially for users who are listening but not actively navigating with the keyboard at that instant, or for users on screen readers that handle focus announcements inconsistently. That is what an aria-live region is for.

Keep a persistent, visually hidden element on every page:

<div aria-live="polite" role="status" id="route-announcer"></div>

On every route change, write a short, human sentence into that node, something like "Navigated to Pricing." Clear it and rewrite it rather than appending, so repeat announcements still fire. The W3C's guidance on ARIA live regions is worth reading directly if you want to understand the difference between polite and assertive, and why polite is almost always the right choice for route announcements: it waits for the screen reader to finish whatever it is currently saying instead of interrupting.

Focus movement and live region announcement are complementary, not redundant. Use both.

3. Trap focus correctly inside an open modal or drawer

Modals introduce their own SPA-specific focus trap, and it is one we have covered in depth in our breakdown of the modal dialog bug that traps screen reader users. The short version, as a checklist:

  • When a modal opens, move focus to the first focusable element inside it, or to the modal's own heading if there is nothing else more urgent.
  • While the modal is open, tabbing must cycle only among the focusable elements inside the modal. Tab past the last one and you land back on the first. Shift+Tab past the first and you land on the last.
  • Elements behind the modal should not be reachable by keyboard while it is open. If you are not confident your modal library enforces this, test it manually with Tab and Shift+Tab before shipping.
  • Escape should close the modal, every time, without exception.

Following the W3C's authoring practices for focus management here will save you from reinventing a pattern that has already been solved and documented in detail, including edge cases like nested dialogs and dynamically inserted content.

4. Restore focus to the trigger element after the modal closes

This is the step teams forget most often, and it is a distinct failure from the trap itself. When a modal closes, whether by Escape, a close button, or a successful form submission, focus needs to return to the element that opened it.

Why it matters: without this step, focus typically falls back to the document body. For a keyboard or screen reader user, that means they are dropped at the top of the page and have to re-navigate all the way back down to where they were working. Multiply that by every modal interaction in a session, and it becomes a genuinely exhausting way to use your product.

The fix:

  • Before opening the modal, store a reference to the element that had focus (usually document.activeElement).
  • When the modal closes, call .focus() on that stored reference.
  • If the trigger element no longer exists (say, it was a "delete" button on a row that just got deleted), focus the nearest sensible fallback, such as the row above it or a parent container heading, rather than letting focus vanish.

Run the checklist against your own app

Route focus, live region announcement, modal trapping, and focus restoration are four separate mechanisms, and an SPA can get three of them right and still fail badly on the fourth. The honest way to know where you stand is to actually tab and listen through your own critical flows: log in, open a settings modal, submit a form, navigate between two main sections.

If you want a second set of eyes on it, our team built a set of free accessibility tools specifically to help you spot exactly these kinds of SPA-specific focus gaps before a user, or an auditor, finds them for you.

Focus management in a single-page app is not one fix. It is a checklist you run against every route and every modal, every time you ship. Get it wrong once and a real user goes silent on your product without ever knowing why.

If you want to talk through your specific setup, reach out to a real person on our team at experts@wcag.world, or start with our free accessibility tools.