Focus Management in Single-Page Apps

Learn how to implement proper focus management in SPAs to create accessible, keyboard-friendly experiences that rival traditional page loads.
While I was looking over some accessibility audits for a client's single-page application the other day, I came across a pattern that was causing major usability issues for keyboard users. Every time they navigated to a new route, focus would just... stay where it was. The user would press Enter on a "View Details" button, the content would change, but their keyboard focus remained on that button. They had no idea new content had loaded.
I was once guilty of this exact same mistake. Little did I know that traditional multi-page applications handle focus management automatically—when a new page loads, the browser resets focus to the document body. In SPAs, we've broken that contract, and we need to fix it ourselves.
Why Focus Management Matters in SPAs
Here's what I realized: focus management isn't just about accessibility compliance. It's about creating a coherent user experience. When someone using a keyboard navigates your application, focus is their cursor. It tells them where they are and what they can interact with.
In a traditional website, every navigation triggers a full page reload. The browser handles focus reset automatically. Your keyboard user knows exactly where they are because they're starting from the top of a new page every time.
In SPAs, we hijack that behavior. We update the DOM dynamically, change the URL, and paint new content—but we never give the browser a chance to reset focus. The result? Keyboard users are left stranded in the wrong context.
I cannot stress this enough! This isn't a minor bug. For users who rely on keyboard navigation or screen readers, poor focus management can make your application completely unusable.
The Focus Problem: What Traditional Page Loads Handle Automatically
Let me show you what I mean. Here's a typical React Router setup that many of us start with:
function App() {
return (
<Router>
<nav>
<Link to="/">Home</Link>
<Link to="/products">Products</Link>
<Link to="/about">About</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/about" element={<About />} />
</Routes>
</Router>
);
}When someone clicks "Products", the URL changes and new content renders. But if they're using Tab to navigate, their focus stays on the "Products" link. They have to Tab through the entire navigation again to reach the new content. Wonderful for frustrating your users!

The fix isn't complicated, but it requires intentionality. We need to manually move focus to the new content whenever the route changes.
Focus Strategies for Route Changes
When I finally decided to tackle this problem properly, I discovered there are a few valid approaches. The key is choosing where to send focus when new content loads.
Here's the pattern I use most often—focusing the main heading of the new page:
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function Products() {
const headingRef = useRef<HTMLHeadingElement>(null);
const location = useLocation();
useEffect(() => {
// Focus the heading when the route changes
if (headingRef.current) {
headingRef.current.focus();
}
}, [location.pathname]);
return (
<main>
<h1
ref={headingRef}
tabIndex={-1}
style={{ outline: 'none' }}
>
Our Products
</h1>
<div className="product-grid">
{/* Product content */}
</div>
</main>
);
}Notice the tabIndex={-1} on the heading. This is crucial. By default, headings aren't focusable. Setting tabIndex={-1} makes them programmatically focusable (via JavaScript) but doesn't add them to the natural tab order. We also remove the focus outline since headings shouldn't show focus indicators like buttons do.
Another approach is to focus a skip link or the main content container itself. The important thing is consistency—pick one strategy and apply it across your entire application.
Managing Focus in Modals and Dialogs
Modals present a different challenge. When a modal opens, focus should move inside it and stay trapped there until the user closes it. When I was building my first modal component, I let focus roam free. Users could Tab right out of the modal and interact with the background page. Fascinating how broken that experience was!
Here's a proper modal implementation that traps focus:
import { useEffect, useRef } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// Store the element that had focus before modal opened
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus the modal container
modalRef.current?.focus();
// Handle Escape key
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
ref={modalRef}
className="modal-content"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
aria-label="Close modal"
>
×
</button>
{children}
</div>
</div>
);
}This pattern does three critical things: it moves focus into the modal when it opens, it stores where focus was before opening, and it restores that focus when the modal closes. In other words, we're creating a complete focus lifecycle.
ARIA Live Regions vs Focus Management: When to Use Each
Here's where I see developers get confused. ARIA live regions and focus management both help users understand when content changes, but they serve different purposes.
Focus management is for major navigational changes—route transitions, modal openings, form submissions that take you to a new view. You're physically moving the user's keyboard cursor to a new location.
ARIA live regions are for announcing updates without moving focus. Think loading states, success messages, validation errors that appear inline. The user stays where they are, but their screen reader announces the change.
I was once guilty of trying to use live regions for everything. I'd slap aria-live="polite" on content that should have received focus instead. The result was a confusing experience where screen reader users heard announcements but couldn't find the new content with their keyboard.
Use focus management when the user's attention should move to the new content. Use live regions when the user should stay where they are but be notified of a change.

Building a Focus Trap Hook for React
Luckily we can extract the focus trap logic into a reusable hook. This is the pattern I use across multiple projects:
import { useEffect, RefObject } from 'react';
export function useFocusTrap(
ref: RefObject<HTMLElement>,
isActive: boolean
) {
useEffect(() => {
if (!isActive || !ref.current) return;
const element = ref.current;
const focusableElements = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
// Focus first element
firstElement?.focus();
const handleTabKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab: if on first element, go to last
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab: if on last element, go to first
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
element.addEventListener('keydown', handleTabKey);
return () => {
element.removeEventListener('keydown', handleTabKey);
};
}, [ref, isActive]);
}Now you can trap focus in any component with just a few lines:
function Dialog({ isOpen, onClose }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, isOpen);
// Rest of component...
}Testing Focus Management with Testing Library
When I finally decided to write tests for focus behavior, I discovered that Testing Library makes this surprisingly straightforward. Here's how I verify focus management in modals:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Modal } from './Modal';
describe('Modal focus management', () => {
it('moves focus into modal when opened', () => {
const { rerender } = render(
<div>
<button>Outside button</button>
<Modal isOpen={false} onClose={() => {}}>
<button>Inside button</button>
</Modal>
</div>
);
const outsideButton = screen.getByText('Outside button');
outsideButton.focus();
expect(outsideButton).toHaveFocus();
// Open modal
rerender(
<div>
<button>Outside button</button>
<Modal isOpen={true} onClose={() => {}}>
<button>Inside button</button>
</Modal>
</div>
);
// Focus should move into modal
expect(outsideButton).not.toHaveFocus();
});
it('restores focus when modal closes', () => {
const handleClose = jest.fn();
const { rerender } = render(
<div>
<button>Trigger</button>
<Modal isOpen={true} onClose={handleClose}>
<button>Close</button>
</Modal>
</div>
);
const trigger = screen.getByText('Trigger');
// Close modal
rerender(
<div>
<button>Trigger</button>
<Modal isOpen={false} onClose={handleClose}>
<button>Close</button>
</Modal>
</div>
);
expect(trigger).toHaveFocus();
});
});These tests verify the complete focus lifecycle. They're fast, reliable, and they've caught real bugs in my code.
Making Focus Management a Default Pattern
The biggest lesson I learned: focus management shouldn't be an afterthought. It needs to be baked into your component architecture from the start.
I now include focus management in every route component, every modal, every drawer, every accordion. It's not extra work—it's fundamental to building usable SPAs.
Here's my checklist for every new feature:
- Does this change the page content? Move focus to the new content's heading.
- Does this open a modal or dialog? Trap focus and restore it on close.
- Does this show a temporary message? Use an ARIA live region.
- Does this load new data? Announce it, but don't move focus unless navigation occurred.
When you make focus management a default pattern rather than a special case, your applications become dramatically more accessible without any extra effort.
And that concludes the end of this post! I hope you found this valuable and look out for more in the future!