I’d been happily shipping a React SPA for a while when an accessibility audit came back with this note:

“The page changed, but the screen reader said nothing.”

The page transition animated smoothly, the URL updated, the content changed… but as far as a screen reader user was concerned, nothing happened at all.

That’s the hidden trap of SPAs.

Traditional Websites vs. SPAs

What happens when you click a link on a traditional multi-page site?

  1. The browser loads a new page
  2. The entire page re-renders
  3. The browser automatically moves focus to the top of the document
  4. The screen reader announces the new page’s title

The browser handled all of that for you. For free.

What about an SPA? The URL changes, but the page never actually reloads — JavaScript just updates the DOM. As far as the browser is concerned, no “navigation” happened, so there’s no reason to move focus.

The result: when a user clicks a link in the nav menu, focus stays right there on that link. The content has completely changed, but focus is still sitting up in the header.

Mouse users never notice. Keyboard and screen reader users get completely lost.

Why Does This Matter?

Put yourself in a screen reader user’s shoes. They click the “About” link in the nav. Nothing changes audibly. They press Tab to start exploring again… and the nav links show up again. There’s no way to tell whether the page changed or focus just moved somewhere else.

Keyboard users hit the same wall. To reach the new page’s actual content, they have to Tab through every nav item in the header all over again. I’ve already covered the basics of keyboard navigation — tab order, skip links, and the like — in Keyboard Accessibility A to Z, so this post focuses specifically on the SPA-specific situation of routing. A skip link would help here, but if focus is still sitting on the old link, the skip link never gets re-triggered either.

This isn’t a feature gap. It’s an equity gap.

Diagram comparing focus flow after a route change - on the left, the neglected pattern: the content has completely changed but focus is still stuck on the header link, so the screen reader stays silent; on the right, the managed pattern: focus moves to the new page's h1, so the About heading is announced immediately
Diagram comparing focus flow after a route change - on the left, the neglected pattern: the content has completely changed but focus is still stuck on the header link, so the screen reader stays silent; on the right, the managed pattern: focus moves to the new page's h1, so the About heading is announced immediately

If you called out the browser on this, it would probably say: “But there was no navigation on my end.” Technically, it’s right. Which is exactly why this is on us to handle.

Managing Focus in React Router

If you’re using React Router, you need to move focus yourself whenever the route changes.

The simplest approach is to focus the topmost element of the page component.

javascript
// In each page component
import { useEffect, useRef } from 'react';

function ProductPage() {
  const headingRef = useRef(null);

  useEffect(() => {
    // Move focus to the heading when the component mounts
    headingRef.current?.focus();
  }, []);

  return (
    <main>
      {/* tabIndex={-1}: not reachable by Tab, but focusable via JavaScript */}
      <h1 ref={headingRef} tabIndex={-1}>Product Overview</h1>
      <p>Content...</p>
    </main>
  );
}

tabIndex={-1} is the key piece here. <h1> isn’t focusable by default. Setting it to -1 keeps it out of the Tab order while still letting JavaScript focus it.

Handling Route Changes in One Place

Repeating the same code in every page component gets tedious. It’s better to handle route changes in one place — wrap your route content in a single component.

If you go this route, remove the per-page approach above. If you leave each page’s <main> and its focus-handling useEffect in place and wrap it with this component too, you end up with two nested <main> elements — and the outer one immediately overwrites the focus the page just set on its h1. Pick one approach, not both.

javascript
// RouteChangeAnnouncer.jsx
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

export default function RouteChangeAnnouncer({ children }) {
  const location = useLocation();
  const mainRef = useRef(null);

  useEffect(() => {
    // Move focus to the main content area whenever the route changes
    mainRef.current?.focus();
  }, [location.pathname]);

  return (
    <main ref={mainRef} tabIndex={-1}>
      {children}
    </main>
  );
}

Wrap the whole set of routes with it, inside the router.

javascript
<BrowserRouter>
  <Header />
  <RouteChangeAnnouncer>
    <Routes>{/* ... */}</Routes>
  </RouteChangeAnnouncer>
  <RouteAnnouncer />   {/* We'll build this next. Focus and announcements are separate concerns */}
</BrowserRouter>

You might be tempted to slap style={{ outline: 'none' }} on that <main> — a heavy outline flashing on every navigation is annoying. But strip it out inline and you take the outline away from mouse and keyboard users alike. Only the mouse user needed it hidden.

CSS can make that distinction for you.

css
/* Outline only when arriving via keyboard */
main:focus-visible {
  outline: 2px solid #6366f1;
  outline-offset: 2px;
}

/* Hide it right after a mouse or touch interaction */
main:focus:not(:focus-visible) {
  outline: none;
}

It’s easy to assume :focus-visible automatically filters out script-driven focus, but that’s not quite right. Among the heuristics the spec recommends to browsers is this one: if the previous focus indicator was showing, a script-moved focus should show one too (a recommendation, not a requirement). I tested it directly on Chrome 152 and that’s exactly what happens — navigating via a keyboard-activated link matches :focus-visible and draws the outline, while a mouse click doesn’t match. Keep that inline style from earlier around, though, and it overrides this heuristic entirely (inline styles win).

Next.js App Router: Announcements, Yes — Focus Movement, No

“Doesn’t Next.js just handle this for you?” Half right. The other half is the problem.

Next.js ships a built-in Route Announcer. There’s a visually hidden region that a screen reader reads aloud whenever its content changes (an aria-live region — we’ll build one by hand later), and Next.js drops document.title into it on every page change, falling back to the <h1> if there’s no title. So a screen reader user hears the name of the new page, something like “Order History.”

But focus itself never actually moves. Up through version 15, Next.js did call focus() on the new segment’s top-level element after routing — but that element had no tabindex, so nothing happened. In version 16, the new scroll handler became the default and even that call was dropped; the code now carries a comment saying focus is intentionally left alone. Either way, the outcome is the same: you get the title announced, but the cursor is still sitting right where you clicked. If that link was in the footer, the user hears the announcement and then still has to Tab from the footer. Announcing and focusing are two separate jobs, and Next.js only covers the first one for you.

So you still need to wire up focus movement yourself.

If your root layout exports metadata, you can’t turn that file into a 'use client' component. In that case, split the focus-moving logic out into a small dedicated client component and keep the layout itself as a server component.

typescript
// app/layout.tsx
'use client';

import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  const mainRef = useRef<HTMLElement>(null);

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

  return (
    <html lang="en">
      <body>
        <header>...</header>
        <main ref={mainRef} tabIndex={-1}>
          {children}
        </main>
      </body>
    </html>
  );
}

Announcing Page Changes with aria-live

Moving focus means the screen reader reads out whatever element now has focus. But it’s also worth explicitly announcing which page you landed on.

An aria-live region lets a screen reader automatically read out new content whenever the DOM changes. I’ve covered how live regions actually work — the difference between polite and assertive, why you need to leave an empty element in place ahead of time — separately in When aria-live Stays Silent — Getting ARIA Live Regions Right.

Diagram comparing the roles of aria-live announcements and focus movement - aria-live tells a screen reader user where they landed, like an Order History page, but leaves the cursor where it was; moving focus takes keyboard users all the way to the start of the new content. Both need to run on every route change
Diagram comparing the roles of aria-live announcements and focus movement - aria-live tells a screen reader user where they landed, like an Order History page, but leaves the cursor where it was; moving focus takes keyboard users all the way to the start of the new content. Both need to run on every route change

The RouteChangeAnnouncer from earlier handles moving focus; the RouteAnnouncer below handles the spoken announcement. Keep both inside the router and use them together — neither one substitutes for the other.

javascript
// RouteAnnouncer.jsx
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

function RouteAnnouncer({ pageTitle }) {
  const location = useLocation();
  const announceRef = useRef(null);

  useEffect(() => {
    if (announceRef.current) {
      // Clear it first, then refill — that's what gets a screen reader to notice
      announceRef.current.textContent = '';
      const id = setTimeout(() => {
        // Check again in case it unmounted within the 100ms window
        if (announceRef.current) {
          announceRef.current.textContent = `Navigated to the ${pageTitle} page`;
        }
      }, 100);
      return () => clearTimeout(id);   // Timers can overlap if routes change back to back
    }
  }, [location.pathname, pageTitle]);

  return (
    <div
      ref={announceRef}
      role="status"
      aria-live="polite"
      aria-atomic="true"
      // Visually hidden, but still read by screen readers
      style={{
        position: 'absolute',
        width: '1px',
        height: '1px',
        overflow: 'hidden',
        clip: 'rect(0, 0, 0, 0)',
        whiteSpace: 'nowrap',
      }}
    />
  );
}

aria-live="polite" doesn’t interrupt whatever’s currently being read — it waits until the screen reader is free. For urgent alerts you’d reach for aria-live="assertive", but for a navigation announcement, polite is plenty.

And yes, you probably spotted the hack. Whenever a setTimeout shows up in an accessibility implementation, something feels off — and you’re right, it does to me too. Sometimes a screen reader doesn’t register a direct textContent change as a “change” at all, so clearing it and refilling it is, for now, the most reliable trick around. Not elegant. But it works.

Try It Yourself — A Demo Comparing No Focus Management vs. Managed Focus

That covers what needs to happen on a route change. But “focus stays on the link” is one of those things that doesn’t quite land until you read it — you have to press Tab yourself to really get it.

Open the SPA focus management comparison demo, flip the toggle at the top between unmanaged and managed, and click through the navigation. A live readout at the bottom of the screen shows exactly where focus is at any moment.

One honest note: the demo interface is in Korean, but the controls are simple enough to follow — a toggle and a few nav links. And the log panel next to it isn’t real screen reader output; it’s a simulation I wrote by hand of what would and wouldn’t get announced in each case. For a real verdict, you’ll want to fire up VoiceOver or NVDA yourself.

Since this is hard to take on faith, I pressed a link in the demo with the keyboard and immediately logged document.activeElement. This is from September 15, 2026, on Chrome 152.

데이터 표
CaseFocus before navigationdocument.activeElement afteraria-live region
No focus managementa “소개” (About)a “소개” (About)Empty
Managed focusa “연락처” (Contact)h1 “연락처” (Contact)“연락처 페이지로 이동했습니다” (Navigated to the Contact page)

In both cases the content changed exactly as it should. The h1 matched too — “소개” (About) or “연락처” (Contact), depending on the page. The only things that differed were focus and the announcement — nothing else.

In the unmanaged case, focus is still sitting right on the link that was just clicked. To someone looking at the screen, the page obviously changed; to someone reading the screen through cursor position, nothing happened at all. The empty aria-live region is the same failure by a different name — nobody put a message in there, so there’s nothing to read.

This is a single measurement on Chrome 152. Safari, Firefox, and actual screen readers may behave differently. I read the values from document.activeElement in the console rather than trusting the demo’s on-screen focus indicator, which doesn’t update the moment focus is lost and just keeps showing the last value. The aria-live region also fills in a beat later than focus does (about 100ms here), so if you’re testing this yourself, wait a moment before reading it.

And the “no focus management” case is a deliberate control group, built to reproduce the common mistake on purpose. Of course focus doesn’t move there. This table isn’t really a discovery — it’s a way of making visible, exactly, what goes wrong when nobody manages focus at all.

That’s the story of what happens “when the page changes.” But the same problem shows up within a page too — whenever you open and close a modal or a dropdown.

Focus Restoration Pattern

There’s an important pattern for modals, dropdowns, and similar UI elements: focus restoration. I’ve covered focus traps — the part that keeps Tab contained inside a modal — in Keyboard Accessibility A to Z.

Say a user clicks a “Delete” button, which opens a confirmation modal. They click “Cancel” to close it… where should focus go?

Back to that “Delete” button.

Diagram of the round-trip focus restoration pattern - clicking the Delete button sends focus into the modal, and canceling closes the modal and returns focus to the original Delete button. A warning notes that without restoration, focus vanishes along with the removed element, forcing keyboard users to start navigating from the top of the document again
Diagram of the round-trip focus restoration pattern - clicking the Delete button sends focus into the modal, and canceling closes the modal and returns focus to the original Delete button. A warning notes that without restoration, focus vanishes along with the removed element, forcing keyboard users to start navigating from the top of the document again
javascript
import { useRef, useState } from 'react';

function DeleteButton({ itemId }) {
  const buttonRef = useRef(null);
  const [isModalOpen, setIsModalOpen] = useState(false);

  function openModal() {
    setIsModalOpen(true);
  }

  function closeModal() {
    setIsModalOpen(false);
    // Restore focus to the button once the modal closes
    setTimeout(() => {
      buttonRef.current?.focus();
    }, 0);
  }

  return (
    <>
      <button ref={buttonRef} onClick={openModal}>
        Delete
      </button>
      {/* Assumes ConfirmModal moves focus to the first element inside it when it opens */}
      {isModalOpen && (
        <ConfirmModal
          onCancel={closeModal}
          onConfirm={() => {
            deleteItem(itemId);
            closeModal();          // Focus goes back to its original spot after deleting too
          }}
        />
      )}
    </>
  );
}

The reason for setTimeout(..., 0) is timing — the modal is still being removed from the DOM. Try to set focus synchronously and the DOM might not have updated yet.

What happens if you skip restoration? When the modal disappears, focus vanishes along with the removed element. The browser resets focus to body. Modern browsers will pick up the next Tab from roughly where the removed element used to be (the sequential focus navigation starting point), but in the meantime, a screen reader user loses their sense of “where am I,” and Shift+Tab goes somewhere unexpected. Remember where focus came from when you open something, and send it back there when you close it — think of it as a round-trip ticket.

It’s Not Just Focus — Tab Titles and Loading States Matter Too

Updating document.title

Along with focus management, you also need to update the page title. In an SPA, the <title> tag doesn’t change on its own.

javascript
// Using React Helmet, or the Next.js Metadata API
import { Helmet } from 'react-helmet-async';

function ProductPage() {
  return (
    <>
      <Helmet>
        <title>Product Overview | My Service</title>
      </Helmet>
      <main>...</main>
    </>
  );
}

That updates the browser tab title too, and lets a screen reader user know which page they’re on.

Handling Loading States

You also need to be careful when showing a skeleton UI while data loads. If you set focus while the skeleton is still showing, the screen reader ends up reading the skeleton element.

javascript
function ProductPage() {
  const { data, isLoading } = useFetchProduct();
  const headingRef = useRef(null);

  useEffect(() => {
    // Move focus only after loading finishes
    if (!isLoading && headingRef.current) {
      headingRef.current.focus();
    }
  }, [isLoading]);

  // aria-label is ignored on an element with no role. Loading is a 'state', not a 'name'.
  if (isLoading) {
    return (
      {/* .sr-only: the familiar utility class that hides content visually while keeping it for screen readers */}
      <div role="status">
        <span className="sr-only">Loading content</span>
        <SkeletonLoader aria-hidden="true" />
      </div>
    );
  }

  return (
    <main>
      <h1 ref={headingRef} tabIndex={-1}>{data.title}</h1>
    </main>
  );
}

Wrapping Up

Focus management in an SPA isn’t optional — it’s baseline. For people who navigate the web without a mouse, and for people using screen readers, an SPA’s “fast page transitions” shouldn’t turn into a trap.

Here’s the core pattern, summarized:

  1. Move focus on route change: programmatically focus an element with tabIndex={-1}
  2. Announce the change with aria-live: insert a navigation message into a role="status" region
  3. Update document.title: keep the page title in sync
  4. Restore focus: return focus to the original element when closing modals and dropdowns
  5. Focus after loading completes: move focus once the data is actually ready

If an SPA is fast and smooth, it should be fast and smooth for every user. Focus management is what makes “every user” actually true.

I finally got to reply to that accessibility feedback with — “Now, the moment the page changes, the screen reader is the first to know.”


질문으로 다시 보기

Why do screen readers stay silent after a route change in an SPA?
Because an SPA doesn’t trigger an actual page load when the URL changes — JavaScript just swaps out the DOM. As far as the browser is concerned, no navigation happened, so focus stays put and there’s nothing to announce to a screen reader. On route change, you need to move focus to a heading or main region with tabIndex={-1} and announce the change through an aria-live region.
When should you use tabIndex={-1}?
Use it when you want an element to stay out of the Tab order but still be focusable via JavaScript’s focus() call. It’s the key ingredient in the pattern of moving focus to an h1 or main element — elements that aren’t focusable by default — after an SPA route change.

More in this series

Browse the full Frontend × Accessibility series — a series that connects an accessibility lens to everyday frontend topics, from focus management to form UX and dark mode.