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;
}Skip Links That Actually Work
Skip links are the most basic keyboard accessibility feature — and the most commonly broken. The pattern is simple: a visually hidden link at the top of the page that becomes visible on focus and jumps to main content. But many implementations fail because they don't account for client-side routing.
Common failures: the target element doesn't have tabindex='-1' (so focus doesn't actually move in some browsers), the skip link disappears after first use (React re-renders), or the link targets an ID that doesn't exist on some pages.
// Skip link component that works with client-side routing
export function SkipLink() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-9999 focus:rounded-lg focus:bg-accent focus:px-4 focus:py-2 focus:text-white focus:shadow-lg"
onClick={(e) => {
e.preventDefault();
const main = document.getElementById('main-content');
if (main) {
main.setAttribute('tabindex', '-1');
main.focus();
main.removeAttribute('tabindex');
}
}}
>
Skip to main content
</a>
);
}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