Skip to content

Focus Management in SPAs

intermediate18 min read

The Silent Accessibility Killer

Single-page applications changed everything about how we build the web. Faster transitions, smoother UX, no full-page reloads. But they also broke something fundamental that most developers never notice: focus management.

When you click a link on a traditional multi-page site, the browser loads a new page and resets focus to the top of the document. Screen readers announce the new page title. Keyboard users start fresh. Everyone knows something changed.

In an SPA? The URL updates, the DOM morphs, new content appears... and the focus stays exactly where it was. The user clicked a link that no longer exists in the DOM. Their focus is now on a ghost element — or worse, it silently resets to the body, forcing them to tab through the entire page from the top to reach the new content.

Sighted mouse users never notice. Keyboard and screen reader users are stranded.

Mental Model

Imagine navigating a building blindfolded, using only the handrail. You walk through a door, but instead of finding yourself in the next room, you're teleported back to the building entrance. The room changed around you, but nobody told you where you are. That's what SPA navigation feels like without focus management.

Why SPAs Break Focus

In a traditional multi-page app, the browser handles focus automatically:

  1. User clicks a link
  2. Browser initiates a full page load
  3. New HTML document arrives
  4. Focus resets to the document root
  5. Screen reader announces the new page title via the title element

SPAs bypass steps 2-5 entirely. Client-side routers intercept the click, call history.pushState(), and swap DOM nodes. The browser has no idea a "page change" happened. It doesn't reset focus. It doesn't announce anything.

// What a client-side router does under the hood
link.addEventListener('click', (e) => {
  e.preventDefault();
  history.pushState(null, '', '/new-page');
  renderNewContent(); // DOM changes, focus doesn't
});

The result: after navigation, focus could be on a removed element, a wrong element, or nowhere meaningful. Screen readers stay silent.

Quiz
What happens to keyboard focus when a SPA navigates to a new route without focus management?

Where Should Focus Go After Route Change?

This is the most debated question in SPA accessibility. There are three main strategies, each with tradeoffs:

Strategy 1: Focus the Main Content Area

Move focus to the main element or a wrapper around the new page content. This is the most common approach and generally the best default.

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

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

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

Setting tabIndex={-1} makes the element programmatically focusable without adding it to the tab order. The user won't accidentally tab to it, but your code can send focus there.

Pros: Simple, reliable, puts the user at the start of the new content.

Cons: Some screen readers announce "main landmark" which adds noise. The focus outline on main can look odd (suppress it with outline: none on [tabindex="-1"] — but only on elements that are not natively focusable).

Strategy 2: Focus the Page Heading

Move focus to the first h1 on the new page. Screen readers will announce the heading text, giving the user immediate context about where they are.

function useFocusHeading() {
  const pathname = usePathname();

  useEffect(() => {
    const heading = document.querySelector('h1');
    if (heading instanceof HTMLElement) {
      heading.tabIndex = -1;
      heading.focus();
    }
  }, [pathname]);
}

Pros: Screen readers announce the heading content, providing great context. Feels natural.

Cons: Relies on every page having an h1. If the heading renders asynchronously (data fetching), you need to wait for it.

Move focus to a skip navigation link at the top of the page. The user can then choose to skip to content or navigate through the header.

function SkipLink() {
  const skipRef = useRef<HTMLAnchorElement>(null);
  const pathname = usePathname();

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

  return (
    <a
      ref={skipRef}
      href="#main-content"
      className="skip-link"
    >
      Skip to main content
    </a>
  );
}

Pros: Gives the user maximum control. Mirrors multi-page behavior.

Cons: Adds friction — the user has to take an extra action. Less common in practice.

Tip

The best strategy depends on your app. For content-heavy apps (docs, blogs, learning platforms), focus the heading works great because it announces context. For app-like interfaces (dashboards, email clients), focus main content is usually better. Pick one approach and apply it consistently.

Quiz
Why should you add tabIndex={-1} to an element you want to focus programmatically?

Announcing Route Changes with aria-live

Moving focus handles keyboard users, but screen reader users also need an explicit announcement that the page changed. This is where aria-live regions come in.

An aria-live region is an element that screen readers monitor for content changes. When the text inside changes, the screen reader announces it — no focus change required.

function RouteAnnouncer() {
  const [announcement, setAnnouncement] = useState('');
  const pathname = usePathname();

  useEffect(() => {
    const pageTitle = document.title;
    setAnnouncement(`Navigated to ${pageTitle}`);
  }, [pathname]);

  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {announcement}
    </div>
  );
}

A few important details:

  • aria-live="polite" waits until the screen reader finishes its current announcement before reading the new text. Use "assertive" only for critical alerts.
  • aria-atomic="true" tells the screen reader to announce the entire region content, not just the changed part.
  • sr-only is a CSS class that visually hides the element while keeping it accessible to screen readers (using the clip-rect technique, not display: none which hides it from assistive technology too).
  • The aria-live region must exist in the DOM before its content changes. If you mount the element and set its content simultaneously, screen readers might miss it.
Common Trap

Using display: none or visibility: hidden on an aria-live region makes it invisible to screen readers. If you need to visually hide it, use the clip-rect pattern: position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap;. Many frameworks provide an sr-only utility class for this.

How Next.js Handles This

Next.js App Router includes a built-in route announcer that creates an aria-live region and announces route changes automatically. If you're using Next.js, you get basic route announcements for free. But you still need to handle focus management yourself — the built-in announcer doesn't move focus.

Focus After Dynamic Content

Route changes aren't the only time focus matters. Any time new content appears in response to a user action, you need to decide where focus should go.

Toast Notifications

Toasts are non-critical messages that appear temporarily. They should not steal focus — that would interrupt whatever the user is doing. Instead, use aria-live to announce them.

function Toast({ message }: { message: string }) {
  return (
    <div role="status" aria-live="polite">
      {message}
    </div>
  );
}

Screen readers announce the toast text. Focus stays where it is. The user isn't interrupted.

Inline Validation Errors

When a form field has an error, you have two choices:

  1. On blur validation: Announce the error with aria-describedby linked to an error message element. Don't move focus — the user is moving to the next field.

  2. On submit validation: Move focus to the first field with an error. This is critical — if there are 10 errors, don't just show them. Put the user on the first problem.

function FormWithValidation() {
  const firstErrorRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const errors = validate();

    if (errors.length > 0) {
      firstErrorRef.current?.focus();
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        ref={firstErrorRef}
        id="email"
        aria-invalid={hasError}
        aria-describedby={hasError ? 'email-error' : undefined}
      />
      {hasError && (
        <span id="email-error" role="alert">
          Please enter a valid email address
        </span>
      )}
    </form>
  );
}

Dynamically Loaded Content

When content loads after an interaction (expanding an accordion, loading more items, fetching data), move focus to the new content only if the user explicitly triggered the load. If content loads automatically (infinite scroll, background refresh), don't move focus.

function LoadMoreSection() {
  const newContentRef = useRef<HTMLDivElement>(null);
  const [items, setItems] = useState(initialItems);

  async function loadMore() {
    const newItems = await fetchMore();
    setItems((prev) => [...prev, ...newItems]);
    requestAnimationFrame(() => {
      newContentRef.current?.focus();
    });
  }

  return (
    <div>
      {items.map((item, i) => (
        <div
          key={item.id}
          ref={i === items.length - 1 ? newContentRef : undefined}
          tabIndex={-1}
        >
          {item.content}
        </div>
      ))}
      <button onClick={loadMore}>Load more</button>
    </div>
  );
}

Notice the requestAnimationFrame before focusing. This ensures the DOM has been updated with the new content before we try to focus it. Without it, the ref might point to a stale element.

Quiz
A toast notification appears to confirm a successful save. How should you handle focus?

Focus Management in Modals and Drawers

Modals are the most focus-intensive pattern in web development. Get them wrong and keyboard users are either trapped outside the modal (unable to interact with it) or trapped inside the page (tabbing through content behind the modal overlay).

The Three Rules of Modal Focus

  1. When a modal opens: move focus to the first focusable element inside the modal (or the modal container itself).
  2. While the modal is open: trap focus inside the modal. Tab and Shift+Tab should cycle through the modal's focusable elements only.
  3. When the modal closes: restore focus to the element that triggered the modal.
function Modal({
  isOpen,
  onClose,
  triggerRef,
  children,
}: {
  isOpen: boolean;
  onClose: () => void;
  triggerRef: React.RefObject<HTMLElement | null>;
  children: React.ReactNode;
}) {
  const modalRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isOpen) return;

    const modal = modalRef.current;
    if (!modal) return;

    modal.focus();

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') {
        onClose();
        return;
      }

      if (e.key !== 'Tab') return;

      const focusable = modal!.querySelectorAll<HTMLElement>(
        'a[href], button:not([disabled]), input:not([disabled]), ' +
        'select:not([disabled]), textarea:not([disabled]), ' +
        '[tabindex]:not([tabindex="-1"])'
      );

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);

  useEffect(() => {
    if (!isOpen) {
      triggerRef.current?.focus();
    }
  }, [isOpen, triggerRef]);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-label="Dialog"
        tabIndex={-1}
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>
  );
}
The inert attribute — the modern alternative to manual focus trapping

Instead of manually intercepting Tab key presses, you can use the inert attribute on the content behind the modal. Setting inert on an element makes it and all its descendants non-interactive: unfocusable, unclickable, and invisible to assistive technology.

<body>
  <div id="app" inert>
    <!-- All page content — now inert -->
  </div>
  <div role="dialog" aria-modal="true">
    <!-- Modal content — only focusable area -->
  </div>
</body>

This is cleaner than manual Tab interception because the browser handles everything. The inert attribute has full browser support as of 2023. If your modal renders via a portal to the document body, this approach is straightforward.

Restoring Focus After Closing Overlays

When a modal, drawer, dropdown, or popover closes, focus must return to the element that opened it. If you don't do this, focus drops to the body and the user has to tab from the top of the page to get back to where they were.

The pattern is straightforward: save a reference to the trigger element before opening, and focus it on close.

function DropdownMenu() {
  const [isOpen, setIsOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);

  function handleClose() {
    setIsOpen(false);
    triggerRef.current?.focus();
  }

  return (
    <>
      <button ref={triggerRef} onClick={() => setIsOpen(true)}>
        Menu
      </button>
      {isOpen && (
        <MenuPopover onClose={handleClose}>
          <MenuItem>Profile</MenuItem>
          <MenuItem>Settings</MenuItem>
          <MenuItem>Sign out</MenuItem>
        </MenuPopover>
      )}
    </>
  );
}

Edge Case: The Trigger Element Gets Removed

Sometimes the action inside an overlay removes the trigger element. For example, a "Delete" confirmation dialog removes the item (and its delete button) from a list. Where should focus go?

The best approach: move focus to a logical neighbor — the previous or next item in the list, or the list container itself.

function handleDeleteConfirm(itemId: string) {
  deleteItem(itemId);
  closeModal();

  requestAnimationFrame(() => {
    const nextItem = document.querySelector<HTMLElement>(
      '[data-list-item]'
    );
    if (nextItem) {
      nextItem.focus();
    } else {
      listContainerRef.current?.focus();
    }
  });
}

useRef for Focus Management in React

The useRef hook is your primary tool for focus management in React. Here are the patterns you'll use most.

Pattern 1: Focus on Mount

function SearchInput() {
  const inputRef = useRef<HTMLInputElement>(null);

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

  return <input ref={inputRef} type="search" aria-label="Search" />;
}

Pattern 2: Focus After State Change

function EditableTitle() {
  const [isEditing, setIsEditing] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (isEditing) {
      inputRef.current?.focus();
    } else {
      buttonRef.current?.focus();
    }
  }, [isEditing]);

  if (isEditing) {
    return (
      <input
        ref={inputRef}
        onBlur={() => setIsEditing(false)}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === 'Escape') {
            setIsEditing(false);
          }
        }}
      />
    );
  }

  return (
    <button ref={buttonRef} onClick={() => setIsEditing(true)}>
      Edit title
    </button>
  );
}

Pattern 3: Callback Refs for Dynamic Elements

When you need a ref for elements that are created dynamically (like list items), use callback refs:

function DynamicList({ items }: { items: string[] }) {
  const lastAddedRef = useRef<HTMLLIElement | null>(null);

  const setLastItemRef = useCallback((node: HTMLLIElement | null) => {
    lastAddedRef.current = node;
    node?.focus();
  }, []);

  return (
    <ul>
      {items.map((item, i) => (
        <li
          key={item}
          ref={i === items.length - 1 ? setLastItemRef : undefined}
          tabIndex={-1}
        >
          {item}
        </li>
      ))}
    </ul>
  );
}

Pattern 4: Focus Scope with Multiple Refs

For complex components with multiple focusable elements, store refs in a map:

function TabPanel({ tabs }: { tabs: Tab[] }) {
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());

  function handleKeyDown(e: React.KeyboardEvent, index: number) {
    let nextIndex: number | null = null;

    if (e.key === 'ArrowRight') {
      nextIndex = (index + 1) % tabs.length;
    } else if (e.key === 'ArrowLeft') {
      nextIndex = (index - 1 + tabs.length) % tabs.length;
    }

    if (nextIndex !== null) {
      e.preventDefault();
      tabRefs.current.get(tabs[nextIndex].id)?.focus();
    }
  }

  return (
    <div role="tablist">
      {tabs.map((tab, i) => (
        <button
          key={tab.id}
          ref={(el) => {
            if (el) {
              tabRefs.current.set(tab.id, el);
            } else {
              tabRefs.current.delete(tab.id);
            }
          }}
          role="tab"
          aria-selected={tab.isActive}
          tabIndex={tab.isActive ? 0 : -1}
          onKeyDown={(e) => handleKeyDown(e, i)}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

This uses the roving tabindex pattern: only the active tab has tabIndex={0}, making it the only one in the tab order. Arrow keys move between tabs. This follows the WAI-ARIA tab pattern exactly.

Quiz
In the roving tabindex pattern for tabs, what tabIndex value should inactive tabs have?

Putting It All Together

Here's a complete route-change focus management hook that combines focus movement and screen reader announcements:

function useRouteFocus() {
  const pathname = usePathname();
  const announcerRef = useRef<HTMLDivElement>(null);
  const isFirstRender = useRef(true);

  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }

    const heading = document.querySelector<HTMLElement>('h1');
    if (heading) {
      heading.tabIndex = -1;
      heading.focus({ preventScroll: false });
    }

    if (announcerRef.current) {
      announcerRef.current.textContent = document.title;
    }
  }, [pathname]);

  return announcerRef;
}

function App({ children }: { children: React.ReactNode }) {
  const announcerRef = useRouteFocus();

  return (
    <>
      <div
        ref={announcerRef}
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      />
      {children}
    </>
  );
}

Notice the isFirstRender guard. On the initial page load, the browser handles focus correctly. You only need to intervene on subsequent client-side navigations. Without this guard, the hook would steal focus from wherever the browser naturally places it on first load.

Quiz
Why does the useRouteFocus hook skip focus management on the first render?
What developers doWhat they should do
Using display: none or visibility: hidden on aria-live regions
display: none and visibility: hidden remove elements from the accessibility tree. Screen readers cannot see or announce them.
Use the sr-only clip-rect pattern to visually hide while keeping accessible
Moving focus to toasts and non-critical notifications
Stealing focus interrupts the user's current task. Toasts should be announced passively while focus stays where the user is working.
Use aria-live regions to announce without moving focus
Forgetting to restore focus when closing modals or dropdowns
Without focus restoration, focus drops to the body element. The user has to tab from the top of the page to return to where they were.
Save a ref to the trigger element and focus it on close
Setting tabIndex={0} on non-interactive wrapper elements like div or main
tabIndex={0} adds the element to the keyboard tab order. Wrapper elements should not be tab stops — they should only receive focus via JavaScript.
Use tabIndex={-1} for programmatic focus targets that should not be tab stops
Trying to focus an element before React has committed the DOM update
React batches state updates. If you call focus() synchronously after a setState, the new DOM nodes may not exist yet.
Use requestAnimationFrame or useEffect to ensure the DOM is ready before calling focus()
Key Rules
  1. 1SPAs must manually manage focus on route change — the browser does not reset focus during client-side navigation
  2. 2Move focus to the main content heading or main landmark after route change, and announce the new page via an aria-live region
  3. 3Use tabIndex={-1} for programmatic focus targets — it makes elements focusable via JavaScript without adding them to the tab order
  4. 4Modals must trap focus inside (Tab cycles within the modal) and restore focus to the trigger on close
  5. 5Toast notifications should never steal focus — use aria-live='polite' to announce them without interruption
  6. 6The aria-live region must already exist in the DOM before its content changes, or screen readers may miss the announcement