Skip to main content

Focus Management in SPAs: Accessibility Patterns

Benjamin Morton

Single-page apps broke something the browser used to do for nothing. On a traditional site, every click loads a new page, so the browser resets focus to the top of the document and a screen reader announces where you've landed. A client-side route change does neither. It swaps the DOM in place, focus stays on the link you just clicked (which no longer exists), and nobody is told the view changed.

For a keyboard or screen reader user, that's disorienting at best and a dead end at worst. This guide walks through every place an SPA drops focus, the code to put it back, and the WCAG 2.2 criteria behind each fix, including the three new focus criteria that landed in 2.2.

TL;DR: A client-side route change swaps the DOM without a page load, so keyboard focus stays on the now-removed link and screen readers never announce the new view. Fix it by moving focus to the new page's <h1 tabindex="-1"> on navigation, which works because 71.6% of screen reader users navigate by headings (WebAIM, 2024). Keep focus visible with :focus-visible, trap it in modals (or use native <dialog>), and never remove an outline without replacing it.

Why Do SPAs Break Focus?

Because the browser's built-in focus handling is tied to page loads, and SPAs removed the page loads. When a route changes client-side, focus stays on the element that triggered the navigation, even after that element is gone, and the screen reader's virtual cursor can reset to the top of body with no announcement of what happened (TPGi, 2026). Two independent accessibility teams, TPGi and Deque, describe the same failure (Deque, 2026).

What a Page Load Does For Free (and an SPA Doesn't) Page load SPA route Focus moves to the new content Screen reader announces the view Keyboard user keeps their place A browser handles all three on a real navigation. In an SPA you implement each one yourself. Source: focus-management guidance from TPGi and Deque, 2026

Here's the awkward part, and it's why this bug is so common: an automated scanner won't catch it. Losing focus on a route change is a runtime interaction, not a static property of the DOM, so a scan of the rendered page sees nothing wrong. This is exactly the class of issue that needs a human tabbing through the app, which is why automated testing is a floor, not a ceiling. If you only scan, you'll ship this every time.

Where Should Focus Go After a Route Change?

Move it to the new view's main heading. The most reliable pattern is to give the new page's <h1> a tabindex="-1" and call .focus() on it after the route renders. That lands the user at the top of the new content and, on a screen reader, reads out the heading, which tells them where they are.

// Move focus to the new view's heading on every route change
function PageHeading({ children }) {
  const headingRef = useRef(null);
  const { pathname } = useLocation();

  useEffect(() => {
    headingRef.current?.focus();
  }, [pathname]);

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

tabIndex={-1} is the key detail: it makes an element programmatically focusable without adding it to the tab order, so mouse users never tab onto a heading, but your script can still focus it. Don't use tabIndex={0} here; that would drop a stray tab stop on every heading.

Screen Reader Users Navigate by Headings 47% Beginners 71.6% All users 78% Advanced The most common way to explore a page, more so the more expert the user. Source: WebAIM Screen Reader Survey #10, 2024

Why the heading and not just the top of the page? Because that's how these users already move around. 71.6% of screen reader users navigate a long page by its headings first, the single most common method, rising to 78% among advanced users (WebAIM Screen Reader Survey #10, 2024). Landing them on the <h1> drops them exactly where they'd have navigated anyway.

The alternative, or complement, is an aria-live region that announces the new page name without moving focus:

<div aria-live="polite" className="sr-only">
  {routeName} page loaded
</div>

Announcing is gentler but leaves the keyboard user's tab position wherever it was. For most apps, moving focus to the heading is the stronger fix; a live region is a good addition, not a replacement.

Citation capsule: In a single-page app, move keyboard focus to the new view's heading after each client-side route change by giving the <h1> a tabindex="-1" and calling .focus(). This orients screen reader users, 71.6% of whom navigate a page by its headings first (WebAIM Screen Reader Survey #10, 2024), and gives keyboard users a sensible starting point instead of stranding them on a removed link.

Doesn't My Framework Handle This?

Mostly no, and this is the most expensive assumption in SPA accessibility. Take the common case: Next.js ships a route announcer by default, an aria-live region that names each client-side navigation, inspecting document.title first, then the <h1>, then the URL (Next.js docs, 2024).

But read that carefully: it announces, it does not move focus. Next.js does not relocate keyboard focus on navigation, and the gap is a known, open request (vercel/next.js#49386). So a keyboard user's tab position still sits on the dead link after routing; they just now hear the page name. The announcer solves half the problem, and the half it leaves is the one people assume is handled. React Router gives you even less out of the box. Whatever your stack, assume you own focus movement until the docs explicitly say the framework moves it, not just announces.

Keep the Focus Indicator Visible

Moving focus is pointless if nobody can see where it went. WCAG 2.2 has a cluster of criteria about the focus indicator, and 2.2 added three new ones:

Success criterion

Level

New in 2.2

2.4.3 Focus Order

A

2.4.7 Focus Visible

AA

2.4.11 Focus Not Obscured (Minimum)

AA

Yes

2.4.12 Focus Not Obscured (Enhanced)

AAA

Yes

2.4.13 Focus Appearance

AAA

Yes

The most common own-goal is deleting the outline. Never ship outline: none without a replacement; you're removing the only cue keyboard users have. Style :focus-visible instead, which shows an indicator for keyboard focus but not for a mouse click on a button:

/* ❌ Removes the indicator for keyboard users */
:focus { outline: none; }

/* ✅ A clear indicator for keyboard focus only */
:focus-visible {
  outline: 3px solid #1a5fb4;
  outline-offset: 2px;
}

:focus-visible has been Baseline since 2022, so you can rely on it. Two thresholds govern how strong the indicator has to be. Non-text Contrast (1.4.11) requires the indicator to clear 3:1 against adjacent colours, and if you're chasing AAA, Focus Appearance (2.4.13) wants an indicator area at least as large as a 2px-thick perimeter of the component, with a 3:1 change between the focused and unfocused states (W3C, 2023). The colour contrast guide covers how to hit 3:1.

New in 2.2, Focus Not Obscured (2.4.11) catches a different problem: a sticky header, footer, or cookie banner covering the element that just received focus. At Level AA the focused item can't be entirely hidden (W3C, 2023). If you have a sticky top bar, reserve room for it with scroll padding so focused elements scroll into the clear:

:root {
  scroll-padding-top: 4rem; /* height of the sticky header */
}

How Do You Trap Focus in a Modal?

You keep focus inside the dialog while it's open, and you hand it back when it closes. The ARIA Authoring Practices Guide sets the contract: on open, move focus into the dialog; Tab and Shift+Tab wrap around inside it rather than escaping to the page behind; Escape closes it; and on close, focus returns to the element that opened it (ARIA APG, 2026).

The good news: you probably don't need a focus-trap library anymore. The native <dialog> element with showModal() does all of it for you, initial focus inside, the rest of the page made inert, the top layer, Escape to close, and focus returned to the opener, and it's been Baseline since March 2022 (MDN, 2026).

<dialog id="settings">
  <h2>Settings</h2>
  <button autofocus>Close</button>
</dialog>
document.querySelector('#settings').showModal();
// showModal() traps focus, makes the rest of the page inert,
// closes on Escape, and returns focus to the opener on close.

If you must hand-roll a modal (a common reason is styling or animation control), reach for the inert attribute to neutralise everything behind it. Marking the background inert removes it from focus order and the accessibility tree in one attribute, and it's been Baseline since 2023:

<div id="app" inert><!-- background, now unfocusable --></div>
<div role="dialog" aria-modal="true"><!-- your modal --></div>

Just remember to remove inert and return focus to the trigger when the modal closes; that last step is the one most custom modals forget.

What About Menus, Tabs, and Other Composite Widgets?

Composite widgets (a menu, a tab list, a grid) should be one tab stop, with arrow keys moving between the items inside. There are two ways to do it. Roving tabindex gives the active item tabindex="0" and every sibling tabindex="-1", then moves that 0 with the arrow keys and calls .focus(); the browser scrolls the newly focused item into view for you. aria-activedescendant keeps DOM focus on the container and points its aria-activedescendant at the active child's id, so focus never actually moves (ARIA APG, 2026).

Use roving tabindex when you want the browser's automatic scroll-into-view and standard focus styling. Use aria-activedescendant for large or virtualised lists where constantly moving real focus is expensive. Either way, the rule is the same: one tab stop in, arrow keys to move, and never a keyboard trap (W3C 2.1.2, 2023). This is the deeper end of the same territory as the ARIA mistakes developers make, where an unhandled role is a promise you still have to keep in code.

Frequently Asked Questions

Where should focus go after a route change in an SPA?

Move it to the new view's main heading. Give the <h1> a tabindex="-1" and call .focus() after the route renders (ARIA APG, 2026). This orients screen reader users, 71.6% of whom navigate by headings (WebAIM, 2024), and gives keyboard users a sensible starting point instead of the removed link.

Does Next.js handle focus on navigation?

No. Next.js includes an aria-live route announcer that names each client-side navigation, but it does not move keyboard focus (Next.js docs, 2024). Focus movement is still your responsibility; the gap is tracked in an open issue (#49386). Don't assume the framework relocates focus just because it announces the page.

How do I trap focus in a modal dialog?

The native <dialog> element with showModal() traps focus, makes the background inert, closes on Escape, and returns focus to the opener, all for free, and it's Baseline since 2022 (MDN, 2026). For a custom modal, mark background content inert and return focus to the trigger on close, matching the ARIA dialog pattern.

Is removing the focus outline ever OK?

Only if you replace it with a visible indicator. WCAG 2.4.7 requires a visible keyboard focus indicator, and 1.4.11 requires it to reach 3:1 contrast (W3C, 2023). Use :focus-visible to show a strong outline for keyboard users without showing one when a mouse clicks a button. Never ship outline: none on its own.

Why don't accessibility scanners catch lost focus?

Because it's a runtime interaction, not a static DOM property. A scanner inspects the rendered page and sees nothing wrong when focus is stranded after a route change. Catching it needs a person tabbing through the app, which is why automated testing is a floor, not a ceiling (Deque, 2026).

The Bottom Line

Single-page apps traded full page loads for speed, and quietly inherited the browser's job of managing focus. On every client-side route change, move focus to the new view's <h1 tabindex="-1"> so keyboard and screen reader users land where they expect, which matters because 71.6% of them navigate by headings (WebAIM, 2024). Don't assume your framework does this; Next.js announces the route but does not move focus.

Then keep that focus usable: a visible :focus-visible indicator at 3:1, nothing obscuring it, focus trapped in modals (let native <dialog> do the work), and composite widgets down to one tab stop. None of it shows up in an automated scan, so tab through your own app, or better, hand someone a keyboard and watch. It's one of the fixes you can't test your way to with a scanner alone, and one of the most felt by the people who rely on it.


Sources