Skip to content
inklu
reactfocus-managementspawcagengineering-workflows

Focus management in single-page apps: what happens after the route changes

Client-side routing breaks the one thing browsers used to do for free — moving focus on navigation. Here is how to fix it in React, and how to announce it.

By Moe· Co-founder, Design10 min read

Open a React app with a screen reader running. Tab to a nav link, press Enter, and listen. In most apps, nothing happens. The URL changes, the entire main region swaps out, a new page renders — and the screen reader says nothing at all. Focus is still sitting on the link you just activated, which now belongs to a page you have left. A sighted user sees a new screen. A screen reader user gets silence and a cursor pointing at a ghost.

This is the single most common accessibility bug in single-page applications, and it exists because client-side routing removed a browser behavior that nobody thought to replace. A full page load resets focus to the top of the document and the screen reader announces the new page title. history.pushState does neither. React Router, Next.js App Router, TanStack Router — none of them move focus for you, and they are right not to, because the correct target depends on your layout. But that means the responsibility landed on you, and most teams have not noticed it.

What the standard actually requires

There is no WCAG success criterion that says "move focus on route change." The failure shows up indirectly, through several criteria at once, which is part of why it slips past.

2.4.3 Focus Order (A) requires that focus order preserve meaning and operability. When focus remains on a link inside a navigation menu while the entire main content has been replaced, the sequence a keyboard user experiences no longer matches the sequence on screen. Tabbing forward from that stale position often lands them at the end of the nav, skipping past content they never knew loaded.

4.1.3 Status Messages (AA) requires that changes in content be communicated to assistive technology without receiving focus. A route change is the largest possible content change. If nothing announces it, the user has no way of knowing that anything happened.

2.4.2 Page Titled (A) requires that pages have descriptive titles. In an SPA there is one <title> element that persists across every route unless you update it. Plenty of apps ship with the same title on all forty screens.

2.4.11 Focus Not Obscured (AA), new in WCAG 2.2, comes into play once you start moving focus programmatically — you can create new failures while fixing the original one. Focus that lands under a sticky header is a 2.4.11 failure, and it is very easy to introduce.

So the fix has three parts: move focus somewhere sensible, update the document title, and announce the change. Doing one without the others produces a half-fix that tests clean and still fails in practice.

Where to send focus

There are three defensible targets, and one that seems obvious but is not.

The <h1> of the new page. This is what I reach for first. The heading is the semantic answer to "where am I," and moving focus there means the screen reader announces the page name immediately. It needs tabIndex={-1} to be programmatically focusable, which does not add it to the tab order.

The <main> landmark. Slightly blunter — the user hears the landmark role and then has to navigate to find the heading — but it works well when a route does not have a single obvious heading, or when the heading renders below a breadcrumb or toolbar the user should hear first.

A dedicated skip target above the content. A visually hidden element positioned at the top of the routed region. This gives you full control over what gets read and avoids fights with layout. It is the most work and the most predictable.

The <body> element is the one that seems obvious and is not. It resets the reading cursor to the top of the document, which sounds like a full page load, but in most SPAs the top of the document is the header and nav — so the user now has to tab through the entire global navigation on every single route change. That is worse than doing nothing, and I have seen it shipped as a fix more than once.

Here is the heading approach, with the pieces that usually get left out:

function RouteFocus({ title, children }) {
  const headingRef = useRef(null)
  const location = useLocation()

  useEffect(() => {
    document.title = `${title} — Acme`
    headingRef.current?.focus()
  }, [location.pathname, title])

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

Three details matter here and all three are commonly wrong.

The dependency array keys on location.pathname, not location. The full location object changes identity on query-string and hash updates, which means a filter change or a tab switch inside the same page will yank focus back to the heading mid-interaction. That is its own problem, and an annoying one to debug.

tabIndex={-1} on the heading is required, and you should pair it with outline: none only if you have a visible focus style of your own. Removing the ring entirely on a programmatic focus target is a 2.4.7 failure waiting to happen. Most teams style h1:focus-visible rather than h1:focus, which quietly suppresses the ring for programmatic focus — that is usually the intent, but make the decision deliberately rather than inheriting it from a reset stylesheet.

Setting document.title in the same effect keeps the title and the focus target in sync. If you set the title in one place and move focus in another, they drift within a release or two.

The timing problem

useEffect runs after commit, which is usually enough. It stops being enough the moment the route suspends.

With React Suspense, streaming server components, or any data-fetching router, the component that owns your heading may not be mounted when the effect fires. Focus lands on nothing, headingRef.current is null, the optional chain swallows it silently, and you ship a fix that does nothing. This is the failure mode I see most often in code review — the implementation is correct and the test passes in a synchronous fixture, and in production it never runs.

Two things help. Put the focus call in the component that renders after the suspense boundary resolves, not in the layout above it. And if you must handle a loading state, focus the loading region and let the live region carry the update when content arrives, rather than trying to guess when the real heading exists.

There is also a race worth knowing about: if you call .focus() and the element is inside a container with a CSS transition or an animated route wrapper, some browsers will scroll the element into view mid-animation and land the viewport somewhere unexpected. focus({ preventScroll: true }) followed by an explicit scrollTo(0, 0) gives you deterministic behavior.

Announcing the change

Moving focus to a heading announces the heading. That covers a lot, but not everything — it does not tell the user that a navigation occurred rather than a section expanding, and it does nothing for routes where you deliberately do not move focus, like a filtered list updating in place.

A single polite live region, mounted once at the app root and never unmounted, handles this:

function RouteAnnouncer({ message }) {
  return (
    <div
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  )
}

The rule that trips everyone: the live region must exist in the DOM before the content changes. If you conditionally render the entire <div aria-live> at the moment you have something to say, screen readers frequently miss it, because they register live regions when they appear and then watch for mutations. Mount the container empty at app start, and change only its text content.

Use polite, not assertive. Assertive interrupts whatever the user is currently hearing, which on a route change means cutting off the announcement of the thing they just activated. Reserve assertive for genuine errors.

Keep the message short and specific — "Billing settings, page loaded" beats "Navigation complete." And if you are moving focus to the heading and announcing, you will get a small amount of duplication. That is fine, and better than the alternative. Some teams announce only on routes where focus does not move; that is cleaner but requires discipline to maintain.

The same live region discipline applies to form submission results, async validation, and toasts. If you have not audited those, our post on accessible forms in React covers the error-announcement half of this in more detail, and modal dialog patterns covers the focus-trap case, which is a different problem with a similar shape.

Testing it without a screen reader in the loop

You should test with VoiceOver, NVDA, or JAWS. You will not do it on every pull request. So here is the cheap version that catches regressions.

In the browser, navigate via a link and immediately run document.activeElement in the console. If it returns the link you clicked, or <body>, the fix is not running. If it returns your heading, it is.

In a test, assert the same thing:

await user.click(screen.getByRole('link', { name: 'Billing' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Billing' }))
  .toHaveFocus()

That one assertion, applied to every top-level route, prevents the entire class of bug from coming back. Add a second assertion on document.title and you have covered 2.4.2 as well.

What a scanner can and cannot see here

Worth being direct about, because this is exactly the boundary where automated tooling gets oversold.

A static scan reads markup. It will reliably catch the structural preconditions: a missing <main> landmark, a route with no <h1>, a heading level that jumps from h1 to h3, a live region with a broken aria-live value, a focusable element with the focus ring suppressed and no replacement. Those are real and they are worth enforcing on every commit, because they are the substrate the rest of this depends on.

What a scan cannot see is whether focus actually moved after the route transition, whether the live region announced, or whether the announcement was useful. Those are behavioral, they only exist at runtime after a user interaction, and no purely automated tool resolves them. If a vendor tells you their scanner certifies your SPA navigation as accessible, be skeptical — check their site for current details, and then ask them to demonstrate it on a suspended route.

inklu scans the half it can actually verify. It runs axe-core v4.11 plus 50-plus rules we wrote on top against WCAG 2.2, across HTML, CSS, SCSS, and JSX including TSX, and where a fix is unambiguous — a missing landmark, an unlabeled region, a heading structure that skips a level — it opens a GitHub pull request with the corrected code. You review the diff like any other PR; nothing lands on your default branch without your approval. Wired into GitHub Actions, it runs on every pull request rather than once a quarter. Source code is purged after each scan. The behavioral half — focus, announcements, whether the flow makes sense end to end — still needs a person with a screen reader, and we would rather say so than pretend otherwise.

The short version

Move focus to the new page's <h1> with tabIndex={-1}, not to <body>. Key the effect on pathname, not the whole location object. Set document.title in the same place. Mount one polite live region at app root, empty, and only change its text. Watch out for suspended routes where your effect fires before the heading exists. Then write the toHaveFocus() assertion so it never silently regresses.

It is maybe forty lines of code across an entire application, and it is the difference between a screen reader user being able to navigate your product and not.

Want the structural half checked automatically on every pull request? Book a demo at inklu.io and we will scan a real route in your codebase live. Questions first — email hello@inklu.io, or see what a scan costs on our pricing page.