The Route Changed and Nobody Noticed: Focus Management in Single-Page Apps

In an SPA, navigation is something you simulate — and if you don't move focus when the view changes, screen reader users are left sitting on a button that seems to do nothing.

focus?route changenobody noticed
Steve
Accessibility (A11Y) Specialist
Apr 16, 2026
6 min read

The bug report was titled "Search is broken," and it came from a screen reader user. His description: "I press the Search button and nothing happens." We couldn't reproduce it. Search worked — results rendered, the URL updated, analytics fired. It took an embarrassingly long pairing session with the reporter to understand what he meant: for him, nothing happened. Focus was still parked on the Search button. No announcement, no movement, no signal of any kind that the entire page below him had been torn down and rebuilt. He pressed the button and the app went silent.

That's the moment I really understood single-page apps' accessibility debt. It isn't div soup or missing alt text — frameworks didn't make those worse. It's that SPAs quietly canceled a contract the browser had been honoring since 1993, and most of us never noticed we were supposed to pick up the payments.

The promise a full page load makes

On a traditional multi-page site, clicking a link triggers a small ceremony. The browser tears down the page, loads the new one, resets focus to the top of the document, and announces the new page title to assistive technology. A screen reader user hears "Search results — Acme Inc." and starts reading. Navigation is perceivable by default.

A client-side router does none of this. It swaps DOM nodes under the user's feet and updates the address bar. Focus stays wherever it was — or worse, the focused element gets unmounted and focus silently drops to <body>, teleporting the user to the top of a page they don't know has changed. Nothing is announced. The visual experience is seamless, which is the whole sales pitch of SPAs; the non-visual experience is a phone line that's gone dead.

The fix isn't abandoning your framework. It's recognizing that in an SPA, you are the browser now, and there are three moments where you have to do a job it used to do: route changes, modals, and notifications.

Route changes: move focus on purpose

When the view changes because of user navigation, two things need to happen: the document title should update, and focus should move to the new content. The pattern I reach for is focusing the new view's heading:

function PageHeading({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLHeadingElement>(null);
  const { pathname } = useLocation();

  useEffect(() => {
    ref.current?.focus();          // skip on initial load if you prefer
  }, [pathname]);

  return <h1 tabIndex={-1} ref={ref}>{children}</h1>;
}

The tabIndex={-1} is the quiet hero: it makes the heading focusable by script without adding it to the Tab order. When focus lands there, the screen reader announces the heading — "Search results, heading level one" — which is nearly the same experience the full page load used to provide. The user knows where they are, and pressing Tab from there enters the new content naturally. (Suppress the default focus ring on it with :focus-visible styling if it bothers your designers; it only receives programmatic focus.)

Some teams prefer announcing route changes through a live region instead of moving focus, and that's a defensible choice for certain flows. The indefensible choice is the default one: doing nothing.

Modals: trap on purpose, then give it back

Modals are the one place where trapping focus is correct behavior — while the dialog is open, Tab should cycle within it, because the page behind it is supposed to be inert. The modern answer is to let the platform do it:

<dialog id="confirm-delete">
  <h2>Delete this project?</h2>
  <p>This can't be undone.</p>
  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="confirm">Delete</button>
  </form>
</dialog>

<script>
  document.querySelector("#confirm-delete").showModal();
</script>

showModal() gives you the focus trap, Escape-to-close, and an inert background essentially for free — behaviors that hand-rolled modal components get wrong in endlessly creative ways. If you're stuck with a custom implementation (or reviewing a generated one — AI assistants produce a lot of modal components, and they miss these details more often than not), here's the contract to check:

  1. When the modal opens, focus moves into it — to the first sensible control, or the dialog itself.
  2. Tab and Shift+Tab cycle within the modal. Focus never escapes into the dimmed page behind.
  3. Escape closes it.
  4. When it closes, focus returns to the element that opened it. This is the most-forgotten step by a wide margin. Without it, the user is dumped at the top of the document, hundreds of Tab stops from where they were working. Dismissing a dialog shouldn't cost someone their place.

That fourth item is my one-question modal review: "Where does focus go when this closes?" If the PR can't answer, the component isn't done.

Toasts: announce without stealing

Notifications invert the modal rule. A toast should never take focus — the user is mid-task, and yanking their cursor to a corner of the screen because a save succeeded is somewhere between rude and destructive. But it still needs to be perceivable to someone who can't see it. That's precisely what live regions are for:

{/* Rendered once, at app mount — not created per toast */}
<div role="status" aria-live="polite" className="sr-only">
  {latestToastMessage}
</div>

When text appears in that region, screen readers announce it at the next graceful pause, without moving focus or interrupting mid-sentence. Two implementation notes that bite people: the live region must exist in the DOM before the message arrives (regions injected simultaneously with their content often don't announce), and polite is almost always right — reserve role="alert" for genuinely urgent failures. Also, if a toast contains an action ("Undo"), auto-dismissing it in three seconds sets up a race that keyboard users lose every time. Give actionable toasts generous timeouts, pause on hover and focus, and offer the same action somewhere persistent.

Three moments to guard

If you take one thing from this, make it the habit of asking, on every SPA pull request that touches navigation or overlays: when the screen changes, where does focus go, and what gets announced? In practice:

  • Route change: update the title, focus the new view's h1 (with tabIndex={-1}). Silence is a bug.
  • Modal open: focus moves in; Tab is trapped; Escape works. Prefer <dialog> and showModal() over hand-rolled traps.
  • Modal close: focus returns to the trigger. Ask this question in review; it's forgotten more than all the others combined.
  • Toast: announced via a pre-rendered polite live region; never focused; never gone before a keyboard user can reach its action.

Our "Search is broken" reporter re-tested the fix — a focused results heading, ten lines of code — and closed the ticket with a comment I've kept: "Works like a real website now." That's the standard, honestly. The browser spent thirty years teaching people what navigation feels like. Our job is to keep the promise our framework opted out of.

focusspamodalsrouting