RegLayer
Technical 13 min read May 10, 2026

Keyboard Navigation Done Right: Focus Management in SPAs

Client-side routing breaks focus. Here's how to implement proper focus management in React, Next.js, and Vue applications.

Why Client-Side Routing Breaks Keyboard Navigation

In traditional server-rendered pages, every navigation resets focus to the top of the document. The browser announces the new page title, and keyboard users start fresh. Client-side routing (React Router, Next.js App Router, Vue Router) breaks this contract completely.

When you navigate in an SPA, the URL changes, content swaps, but focus stays wherever it was — often on a now-invisible element. Screen readers don't announce the new page. Keyboard users are lost in a void. This is the #1 keyboard accessibility issue we detect, present in 67% of SPAs scanned.

Three Focus Management Patterns

There are three valid approaches to managing focus on route change. Each has tradeoffs depending on your app's structure and user expectations.

Pattern 1 — Focus the main heading: After navigation, programmatically focus the <h1> of the new page. This announces the page title to screen readers and positions keyboard users at the start of content. Add tabindex='-1' to the heading so it can receive focus without being in the tab order.

Pattern 2 — Focus the main content region: Move focus to the <main> element. This skips the navigation entirely (like a skip link) and places users at the content boundary. Works well for apps with persistent navigation.

Pattern 3 — Reset to document top: Move focus to a visually hidden element at the very top of the page, before the skip link. This mimics full-page navigation behavior. Best for document-heavy sites where users expect to start from scratch.

// Pattern 1: Focus heading on route change (Next.js App Router)
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';

export function FocusManager() {
  const pathname = usePathname();
  const isFirstRender = useRef(true);

  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }
    // Wait for DOM to update
    requestAnimationFrame(() => {
      const h1 = document.querySelector('h1');
      if (h1) {
        h1.setAttribute('tabindex', '-1');
        h1.focus({ preventScroll: false });
      }
    });
  }, [pathname]);

  return null;
}

Modal Focus Traps: The Right Way

When a modal opens, focus must be trapped inside it — Tab and Shift+Tab should cycle through focusable elements within the modal only. When it closes, focus must return to the trigger element. Getting this wrong creates keyboard traps (WCAG 2.1.2 failure).

The native <dialog> element handles this automatically with showModal(). If you're building custom modals, you need: focus the first focusable element on open, trap Tab/Shift+Tab within the modal, close on Escape, and return focus to the trigger on close.

  • Use native <dialog> with showModal() whenever possible — it handles focus trapping natively
  • For custom modals: focus first focusable element on open (not the close button)
  • Trap focus using a sentinel element or by intercepting Tab on the last/first elements
  • Close on Escape key — this is a WCAG requirement (2.1.2 No Keyboard Trap)
  • Store the trigger element reference and return focus on close
  • Set aria-modal='true' and role='dialog' with aria-labelledby pointing to the title

Testing Keyboard Navigation Systematically

The fastest keyboard test: put your mouse in a drawer and try to complete your app's critical user flows using only the keyboard. If you get stuck, your users do too.

Key checkpoints: Can you reach every interactive element with Tab? Is focus always visible? Does Escape close overlays? Can you activate buttons with Enter and Space? Do dropdowns work with Arrow keys? After closing a modal, does focus return to where you were?

  • Tab through every page — verify focus indicator is always visible
  • Enter every modal/dropdown — verify you can escape and focus returns
  • Navigate to a new page via keyboard — verify focus moves to meaningful content
  • Complete the primary user flow (e.g., start a scan) using only keyboard
  • Test with screen reader (VoiceOver: Cmd+F5 on Mac) — verify route changes are announced

Detect keyboard traps automatically

RegLayer's scanner identifies focus traps, missing skip links, and broken tab order in seconds.

Run Free Scan