Focus Management in SPAs
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.
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:
- User clicks a link
- Browser initiates a full page load
- New HTML document arrives
- Focus resets to the document root
- Screen reader announces the new page title via the
titleelement
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.
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.
Strategy 3: Focus a Skip Link
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.
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.
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-onlyis a CSS class that visually hides the element while keeping it accessible to screen readers (using the clip-rect technique, notdisplay: nonewhich hides it from assistive technology too).- The
aria-liveregion must exist in the DOM before its content changes. If you mount the element and set its content simultaneously, screen readers might miss it.
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:
-
On blur validation: Announce the error with
aria-describedbylinked to an error message element. Don't move focus — the user is moving to the next field. -
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.
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
- When a modal opens: move focus to the first focusable element inside the modal (or the modal container itself).
- While the modal is open: trap focus inside the modal. Tab and Shift+Tab should cycle through the modal's focusable elements only.
- 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.
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.
| What developers do | What 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() |
- 1SPAs must manually manage focus on route change — the browser does not reset focus during client-side navigation
- 2Move focus to the main content heading or main landmark after route change, and announce the new page via an aria-live region
- 3Use tabIndex={-1} for programmatic focus targets — it makes elements focusable via JavaScript without adding them to the tab order
- 4Modals must trap focus inside (Tab cycles within the modal) and restore focus to the trigger on close
- 5Toast notifications should never steal focus — use aria-live='polite' to announce them without interruption
- 6The aria-live region must already exist in the DOM before its content changes, or screen readers may miss the announcement