Skip to post content
3 min readDaniel Kosbab

Focus management in SPAs: the actual rules

Single-page applications have a default failure mode that multi-page sites don't. When the route changes but the page doesn't reload, focus doesn't reset. The user is left focused on an element that may no longer exist, reading content that hasn't been announced.

Every major framework (React, Next, Vue, SvelteKit) ships this broken by default. Here is what actually works.

The problem in detail

A traditional multi-page site handles focus for you. The browser resets focus on each page load. Screen readers announce the new page title. The user knows they've navigated.

SPAs break both. Routing is handled in JavaScript. The DOM changes, but the browser does not see a navigation event. Focus stays where it was. The screen reader announces nothing.

For a keyboard user: Tab goes somewhere confusing. For a screen reader user: no announcement, no indication the page changed. For a low-vision user using zoom: the viewport stays in the old position, showing the wrong content.

The rules

On route change:

  1. Move focus to the new page's main heading. Not the top of the document. Not a skip link. The h1 of the new page. Add tabindex="-1" to make it programmatically focusable, then call .focus() after the route commits.
  2. Announce the new page title. Update document.title before moving focus. Screen readers read the title on focus move. If the title change is subtle, add an explicit aria-live="polite" region with the new title.
  3. Scroll to the top. Just scroll. No fancy animation. Users expect new page, new scroll position.
  4. Don't move focus mid-interaction. If the route change was triggered by submitting a form inline, focus belongs on the confirmation, not on a page heading elsewhere.

On modal or dialog open:

  1. Move focus into the modal. First focusable element, or the modal's own heading if it has one.
  2. Trap focus inside. Tab should cycle within the modal, not escape to the page underneath.
  3. Return focus on close. When the modal closes, focus goes back to the element that opened it. Store a reference when opening. Restore it on close. Five lines of code. Frequently missing.

On dynamic content change:

  1. If content is replacing what the user is reading, don't move focus. Announce the change with aria-live instead.
  2. If content is inserting a new actionable region (a toast, a notification), don't move focus there either. Users did not ask to be interrupted.

What the frameworks don't do

Next.js, React Router, Vue Router, SvelteKit: none handle focus on route change by default. Some offer plugins. Some expose hooks. Most projects don't enable them.

The framework authors are not wrong to leave this to applications. Focus behavior is contextual. A generic solution would paper over real differences. But "contextual" gets translated in practice into "nobody does it."

The minimum viable implementation

In a React app with useRouter:

useEffect(() => {
  const heading = document.querySelector("h1");
  if (heading) {
    heading.setAttribute("tabindex", "-1");
    heading.focus();
  }
  window.scrollTo(0, 0);
}, [router.pathname]);

Five lines. Applies to every route. Fixes the single largest SPA accessibility failure.

You can be fancier. You can announce route changes through a dedicated live region. You can handle the transition animation timing so focus lands after the animation ends. You can give each route its own specific focus target.

Start with the five lines.

The test

Open your site. Unplug the mouse. Tab around. Navigate to a second page with Enter. Keep tabbing.

If after the route change the next Tab takes you somewhere unrelated to the page you just arrived at, focus management is broken.

If that test fails, everything else in your accessibility work is compromised. None of the screen-reader tooling can tell the user where they are, because where they are is wrong.

© 2026 Daniel Kosbab

Built with love and Tailwind CSS