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. Maddening.
When you delete a list item, focus often vanishes into the void or jumps to the address bar. Expand an accordion? Focus stays on the trigger while new content appears below, leaving users to Tab blindly hoping to find it.
Solution
The core principle: when the UI changes in a way that affects what users should interact with next, move focus to where it logically belongs.
Open a modal? Move focus into it. Close it? Return focus to the trigger element. Navigate to a new page in an SPA? Focus the page heading so screen reader users start at the new content rather than tabbing through navigation they’ve already passed.
Delete a list item? Move focus to the next item, or the previous if deleting the last. Show an important notification? Consider focusing it so screen readers announce the change.
Call element.focus() to move focus programmatically. For non-interactive elements like headings or containers, add tabindex="-1" to make them focusable via JavaScript without adding them to Tab navigation.
Store a reference to the previously focused element before opening overlays. You’ll need it to restore focus when they close.
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.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 { useLocation } from 'react-router-dom';
function App() {
const location = useLocation();
const mainContentRef = useRef(null);
useEffect(() => {
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 deleting items, move focus to the next item so users don’t lose their place:
function TodoList({ items, onDelete }) {
const itemRefs = useRef({});
const handleDelete = (id, index) => {
onDelete(id);
const nextIndex = index < items.length - 1 ? index : index - 1;
const nextItem = items[nextIndex];
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. They always know where they are.
- WCAG 2.4.3 (Focus Order) compliance requires proper focus management; it’s not optional for accessible applications.
- 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
- Incorrect focus placement is worse than none at all. Moving focus unexpectedly disorients users.
- 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. Test across multiple.
- Focus indicators require balance: too subtle fails users, too prominent draws designer complaints.
- 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. Move focus intentionally to new content, restore it when dialogs close, and announce changes to screen readers. Thoughtful focus management makes SPAs feel as natural to keyboard users as traditional multi-page sites.