Focus Management
Problem
Keyboard users get lost when focus is not managed properly during UI changes. Opening a modal dialog leaves focus on the trigger button beneath it, forcing users to tab through dozens of elements in the page content behind the modal to reach the close button or form fields inside the modal, while after navigating to a new route via client-side routing, focus remains on the navigation link at the bottom of the previous page, requiring keyboard users to tab backward through the entire navigation to reach the new page heading.
Screen reader users receive no indication that new content appeared on the page when content loads dynamically or components update. Closing modals returns focus to nowhere, leaving keyboard users disoriented about where they are in the document.
Deleting list items removes the focused element from the DOM, causing focus to jump to the browser address bar or nowhere. Expanding accordions or showing dropdown menus doesn’t move focus to the new content, forcing users to tab forward blindly hoping to find what appeared.
Without proper focus management, keyboard navigation becomes a frustrating maze where users lose their place constantly.
Solution
Programmatically move keyboard focus to appropriate elements as UI changes, especially when showing or hiding content, navigating between routes, or dynamically inserting new elements.
When opening modal dialogs, move focus to the dialog container or first focusable element inside. When closing modals, restore focus to the trigger element that opened the modal.
After route changes, move focus to the page heading or main content landmark so keyboard users start at the top of the new content. When deleting focused elements, move focus to the next logical element like the adjacent list item.
When showing new content like notifications or alerts, optionally move focus to them so screen readers announce their presence. Use element.focus() to programmatically set focus, and set tabindex="-1" on non-interactive elements to allow programmatic focus without adding them to the tab order.
This ensures keyboard users can navigate logically without getting lost or trapped in unexpected locations.
Example
This example demonstrates moving keyboard focus to a modal dialog when it opens so keyboard users can immediately interact with it.
Modal Focus Management
// Web Component that manages focus when modal state changes
class Modal extends HTMLElement {
constructor() {
super();
this.previouslyFocusedElement = null;
}
set open(value) {
this._open = value;
if (value) {
// Save reference to element that had focus before opening modal
this.previouslyFocusedElement = document.activeElement;
this.render();
// Programmatically move focus to modal dialog for keyboard users
const dialog = this.querySelector('[role="dialog"]');
dialog?.focus();
} else {
// Restore focus to element that opened the modal
this.previouslyFocusedElement?.focus();
this.previouslyFocusedElement = null;
}
}
render() {
if (this._open) {
// tabindex="-1" allows programmatic focus but not tab-to-focus
this.innerHTML = `
<div role="dialog" aria-modal="true" tabindex="-1">
<h2>Modal Title</h2>
<p>Modal content</p>
<button class="close">Close</button>
</div>
`;
// Set up close button handler
this.querySelector('.close').addEventListener('click', () => {
this.open = false;
});
}
}
}
customElements.define('modal-dialog', Modal);
React Modal with Focus Management
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children }) {
const dialogRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Save previously focused element
previousFocusRef.current = document.activeElement;
// Move focus to dialog
dialogRef.current?.focus();
} else {
// Restore focus when modal closes
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
ref={dialogRef}
tabIndex={-1}
className="modal"
>
<h2>Modal Title</h2>
<div>{children}</div>
<button onClick={onClose}>Close</button>
</div>
);
}
Route Change Focus Management
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function App() {
const location = useLocation();
const mainContentRef = useRef(null);
useEffect(() => {
// Move focus to main content heading after route change
mainContentRef.current?.focus();
}, [location.pathname]);
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<main>
{/* tabindex="-1" allows focus but not tab navigation */}
<h1 ref={mainContentRef} tabIndex={-1}>
{getPageTitle(location.pathname)}
</h1>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
</main>
</div>
);
}
Skip Link for Keyboard Navigation
<!-- Allow keyboard users to skip navigation -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<nav>
<!-- Navigation links -->
</nav>
<main id="main-content" tabindex="-1">
<!-- Main content -->
</main>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: white;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>
Notification Focus Management
function NotificationManager() {
const notificationRef = useRef(null);
const showNotification = (message) => {
// Add notification to UI
setNotifications(prev => [...prev, { id: Date.now(), message }]);
// Move focus to notification for screen reader announcement
// Use setTimeout to wait for DOM update
setTimeout(() => {
notificationRef.current?.focus();
}, 0);
};
return (
<div className="notifications">
{notifications.map(notification => (
<div
key={notification.id}
ref={notificationRef}
role="alert"
tabIndex={-1}
>
{notification.message}
</div>
))}
</div>
);
}
List Item Deletion Focus Management
function TodoList({ items, onDelete }) {
const listItemRefs = useRef({});
const handleDelete = (id, index) => {
onDelete(id);
// Move focus to next item, or previous if deleting last item
const nextIndex = index < items.length - 1 ? index : index - 1;
const nextItem = items[nextIndex];
if (nextItem) {
// Wait for DOM update
setTimeout(() => {
listItemRefs.current[nextItem.id]?.focus();
}, 0);
}
};
return (
<ul>
{items.map((item, index) => (
<li
key={item.id}
ref={el => listItemRefs.current[item.id] = el}
tabIndex={-1}
>
{item.text}
<button onClick={() => handleDelete(item.id, index)}>
Delete
</button>
</li>
))}
</ul>
);
}
Accordion Focus Management
function Accordion({ items }) {
const [expandedId, setExpandedId] = useState(null);
const contentRefs = useRef({});
const handleToggle = (id) => {
const newExpandedId = expandedId === id ? null : id;
setExpandedId(newExpandedId);
// Move focus to expanded content
if (newExpandedId) {
setTimeout(() => {
contentRefs.current[newExpandedId]?.focus();
}, 0);
}
};
return (
<div>
{items.map(item => (
<div key={item.id}>
<button
aria-expanded={expandedId === item.id}
aria-controls={`content-${item.id}`}
onClick={() => handleToggle(item.id)}
>
{item.title}
</button>
{expandedId === item.id && (
<div
id={`content-${item.id}`}
ref={el => contentRefs.current[item.id] = el}
tabIndex={-1}
>
{item.content}
</div>
)}
</div>
))}
</div>
);
}
Vue Focus Management
<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"
>
<h2>Modal Title</h2>
<button @click="closeModal">Close</button>
</div>
</template>
Focus Visible Styling
/* Only show focus indicators for keyboard navigation */
button:focus {
outline: none; /* Remove default outline */
}
button:focus-visible {
outline: 2px solid blue;
outline-offset: 2px;
}
/* Ensure focus indicators are always visible for keyboard users */
*:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Programmatic Focus Helper
function focusElement(selector, options = {}) {
const {
preventScroll = false,
delay = 0
} = options;
const focusWithDelay = () => {
const element = typeof selector === 'string'
? document.querySelector(selector)
: selector;
if (element) {
element.focus({ preventScroll });
return true;
}
return false;
};
if (delay) {
setTimeout(focusWithDelay, delay);
} else {
return focusWithDelay();
}
}
// Usage
focusElement('#modal-dialog');
focusElement(buttonRef.current, { delay: 100 });
Focus Trap Prevention
function Dialog({ children, onClose }) {
useEffect(() => {
const handleTabKey = (e) => {
if (e.key === 'Tab') {
const focusableElements = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Trap focus within dialog
if (e.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
};
document.addEventListener('keydown', handleTabKey);
return () => document.removeEventListener('keydown', handleTabKey);
}, []);
return <div role="dialog">{children}</div>;
}
Benefits
- Ensures keyboard users can navigate efficiently without tabbing through entire pages to reach new content or controls.
- Required for WCAG 2.1 Level AA compliance under Success Criterion 2.4.3 (Focus Order) and essential for accessibility.
- Prevents users from getting lost after UI changes like route transitions, modal opens/closes, or dynamic content insertion.
- Improves experience for screen reader users by moving focus to new content so changes are announced immediately.
- Essential for modal dialogs and dynamic content where focus management is the difference between usable and unusable interfaces.
- Provides logical navigation flow that matches visual layout and user expectations.
- Enables users to orient themselves quickly after interactions by placing focus in predictable locations.
Tradeoffs
- Can be disorienting if focus jumps unexpectedly to locations users didn’t anticipate, interrupting their intended navigation flow.
- Requires careful thought about where focus should go in each scenario - wrong decisions create worse experiences than no focus management.
- Easy to get wrong and create unclear experiences where focus lands on non-interactive elements or skips over important content.
- May interfere with user expectations if focus moves when users expected to continue from their current location.
- Testing requires actual keyboard and screen reader usage across different browsers - automated tools cannot verify focus behavior feels natural.
- Focus indicators must be carefully styled to be visible without being obtrusive - too subtle and keyboard users can’t see where they are, too prominent and it looks broken.
- Different screen readers announce focus changes differently - NVDA, JAWS, and VoiceOver have varying behaviors that require testing across all platforms.
tabindex="-1"on headings or containers is necessary for programmatic focus but adds complexity to HTML and can confuse developers unfamiliar with the technique.- Focus restoration after modal close requires storing references to previously focused elements, adding state management complexity.
- Asynchronous DOM updates require
setTimeoutor framework-specific delays (likenextTickin Vue) to focus elements that don’t exist yet, creating timing dependencies. - Browser differences in focus behavior - some browsers scroll focused elements into view automatically, others don’t, requiring
scrollIntoViewcalls. - Focus management in single-page applications conflicts with browser history - focus shouldn’t move when users use back/forward buttons if content doesn’t change.
- Overly aggressive focus management that moves focus on every small UI change creates a jarring experience where users constantly lose their place.
- Screen reader virtual cursor vs focus distinction - screen reader users navigate with virtual cursor which may not match DOM focus, creating complexity in understanding user position.
- Focus trapping in modals requires additional logic to prevent tab key from escaping the modal, adding implementation complexity beyond basic focus management.