Focus Management
Control focus flow for modal dialogs, route changes, and dynamic content insertion.
In this article
Focus Management
Problem
A user opens a modal dialog, but focus stays on the button behind the overlay. When they press Tab expecting to reach the modal’s close button, they instead cycle through the entire page beneath it, completely lost.
I’ve tested applications where navigating to a new page left focus stuck on the clicked navigation link, forcing keyboard users to Tab through thirty elements just to reach the new content, which is maddening.
When you delete a list item, the focused element is gone and focus often drops back to the document body, so the next Tab starts from the top of the page. When an accordion expands, focus stays on the trigger while new content appears below, and users have to Tab around hoping to find it.
Solution
When the UI changes in a way that affects what users should interact with next, focus should move to where it logically belongs.
Opening a modal moves focus into it, and closing it returns focus to the trigger element. Navigating to a new page in an SPA moves focus to the page heading, so screen reader users start at the new content rather than tabbing through navigation they’ve already passed.
Deleting a list item moves focus to the next item, or to the previous one if the deleted item was last. An important notification can take focus too, so screen readers announce the change.
element.focus() moves focus programmatically. Non-interactive elements like headings or containers need tabindex="-1", which makes them focusable from JavaScript without adding them to Tab navigation.
Before an overlay opens, the code stores a reference to the previously focused element, so it can put focus back there when the overlay closes.
Example
Modal Focus Management
class Modal extends HTMLElement {
constructor() {
super();
this.previouslyFocusedElement = null;
}
set open(value) {
this._open = value;
if (value) {
this.previouslyFocusedElement = document.activeElement;
this.render();
this.querySelector('[role="dialog"]')?.focus();
} else {
this.innerHTML = '';
this.previouslyFocusedElement?.focus();
this.previouslyFocusedElement = null;
}
}
render() {
if (this._open) {
this.innerHTML = `
<div role="dialog" aria-modal="true" tabindex="-1">
<h2>Modal Title</h2>
<button class="close">Close</button>
</div>
`;
this.querySelector('.close').addEventListener('click', () => {
this.open = false;
});
}
}
}
customElements.define('modal-dialog', Modal); import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children }) {
const dialogRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement;
dialogRef.current?.focus();
} else {
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" ref={dialogRef} tabIndex={-1}>
{children}
<button onClick={onClose}>Close</button>
</div>
);
} <script setup>
import { ref, watch, nextTick } from 'vue';
const isModalOpen = ref(false);
const modalRef = ref(null);
let previouslyFocusedElement = null;
const openModal = () => {
previouslyFocusedElement = document.activeElement;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
previouslyFocusedElement?.focus();
};
watch(isModalOpen, async (isOpen) => {
if (isOpen) {
await nextTick();
modalRef.value?.focus();
}
});
</script>
<template>
<button @click="openModal">Open Modal</button>
<div v-if="isModalOpen" ref="modalRef" role="dialog" tabindex="-1">
<button @click="closeModal">Close</button>
</div>
</template> Route Change Focus Management
Moving focus to the main heading after navigation helps screen reader users find new content immediately:
import { useEffect, useRef } from 'react';
import { Routes, useLocation } from 'react-router-dom';
import { getPageTitle } from './routes';
// Rendered inside <BrowserRouter>, so useLocation() has a router to read from
function App() {
const location = useLocation();
const mainContentRef = useRef(null);
const isFirstRender = useRef(true);
useEffect(() => {
// Leave focus alone on the initial page load; only move it on navigation
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
mainContentRef.current?.focus();
}, [location.pathname]);
return (
<main>
<h1 ref={mainContentRef} tabIndex={-1}>
{getPageTitle(location.pathname)}
</h1>
<Routes>{/* routes */}</Routes>
</main>
);
}
Skip Link
Skip links let keyboard users bypass repetitive navigation:
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav><!-- Navigation --></nav>
<main id="main-content" tabindex="-1"><!-- Content --></main>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: #000;
color: white;
}
.skip-link:focus { top: 0; }
</style>
List Item Deletion
When an item is deleted, focus moves to the next item so users don’t lose their place:
function TodoList({ items, onDelete }) {
const itemRefs = useRef({});
const handleDelete = (id, index) => {
onDelete(id);
// The item after the deleted one, or the one before if it was last
const nextItem = items[index + 1] ?? items[index - 1];
if (nextItem) {
setTimeout(() => itemRefs.current[nextItem.id]?.focus(), 0);
}
};
return (
<ul>
{items.map((item, index) => (
<li key={item.id} ref={el => itemRefs.current[item.id] = el} tabIndex={-1}>
{item.text}
<button onClick={() => handleDelete(item.id, index)}>Delete</button>
</li>
))}
</ul>
);
}
Focus Trap
Trapping focus within modals prevents keyboard users from interacting with content behind overlays:
function Dialog({ children }) {
const dialogRef = useRef(null);
useEffect(() => {
const handleTab = (e) => {
if (e.key !== 'Tab') return;
const focusable = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
};
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}, []);
return <div role="dialog" ref={dialogRef}>{children}</div>;
}
Focus Visible Styling
Using :focus-visible shows focus outlines only for keyboard navigation:
*:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Benefits
- Keyboard users stay oriented as focus moves logically with UI changes.
- WCAG 2.4.3 (Focus Order) compliance requires proper focus management.
- Screen readers announce content when focus moves to it, so users know when things change.
- Modal dialogs become usable for keyboard users who otherwise can’t reach overlay content.
- Consistent patterns build muscle memory, making your application feel natural over time.
Tradeoffs
- Moving focus to the wrong place, or moving it unexpectedly, can disorient users more than leaving it alone.
- Determining the “right” focus target for each scenario requires careful thought; it’s not always obvious.
- Async DOM updates complicate things: you can’t focus elements that don’t exist yet, so you’ll need
setTimeoutor framework utilities likenextTick. tabindex="-1"on headings confuses developers unfamiliar with the pattern, but it’s needed for programmatic focus.- Screen readers (NVDA, JAWS, VoiceOver) announce focus changes differently, so it’s worth testing in more than one.
- Focus indicators need balancing: one faint enough to keep designers happy may be too faint for keyboard users to see.
- Browser back/forward navigation shouldn’t move focus if content hasn’t meaningfully changed, but many implementations miss this.
- Overly aggressive focus management is jarring; moving focus on every small change makes users lose their place.
Summary
Focus management ensures keyboard focus moves logically during dynamic changes: route navigation, modal openings, and content updates. Focus moves deliberately to new content, goes back to the trigger when dialogs close, and lands where screen readers can announce the change. Thoughtful focus management makes SPAs feel as natural to keyboard users as traditional multi-page sites.
See also
- Keyboard Navigation — Accessibility
- Focus Trap — Accessibility
- Accessible Forms — Accessibility
- Focus Management - MDN
- Managing Focus - WAI-ARIA - W3C
- Accessible Focus Indicators - W3C