// guide
Focus Management Patterns for Accessible Web Apps
Focus management is one of the most critical — and most frequently broken — aspects of web accessibility. When keyboard focus is lost, trapped incorrectly, or moved without warning, screen reader and keyboard users lose their place entirely. This guide covers the core patterns for managing focus predictably: trapping focus in modals, restoring focus after dismissals, implementing roving tabindex for composite widgets, and using programmatic focus for dynamic content.
// 01 · why focus management matters
Why Focus Management Matters
Sighted mouse users can click anywhere on the page to interact with an element. Keyboard users rely entirely on focus to navigate — they Tab through interactive elements, press Enter or Space to activate them, and use arrow keys within composite widgets. When a developer opens a modal but doesn't move focus into it, a keyboard user keeps tabbing through the background content, unable to reach the dialog that's visually front and center.
Screen reader users depend on focus even more fundamentally. A screen reader announces the element that currently has focus. When focus is lost — for example, when a popover closes and the trigger button has been removed from the DOM — the screen reader falls silent or jumps to the top of the page. The user has no way to know where they were or what happened.
Poor focus management is one of the most common accessibility failures on the web. The WebAIM Million report consistently finds missing focus indicators, focus traps that don't work correctly, and dynamic content changes that leave keyboard users stranded. These problems affect not only screen reader users but anyone who navigates with a keyboard: people with motor disabilities, power users, and people with temporary injuries.
The WCAG success criteria most relevant to focus management are:
- 2.1.1 Keyboard (A) — All functionality must be operable through a keyboard interface
- 2.1.2 No Keyboard Trap (A) — Focus must never be locked in a component with no way to exit
- 2.4.3 Focus Order (A) — Focus order must be logical and predictable
- 2.4.7 Focus Visible (AA) — Focus indicators must be visible when an element receives focus
- 2.4.11 Focus Not Obscured (Minimum) (AA) — Focused elements must not be entirely hidden by other content (new in WCAG 2.2)
- 2.4.13 Focus Appearance (AAA) — Focus indicators must meet minimum size and contrast requirements (new in WCAG 2.2)
// 02 · focus trapping
Focus Trapping
Focus trapping keeps keyboard focus inside a specific container, preventing users from tabbing out into the background content. This is essential for modal dialogs, where the user must interact with the dialog before returning to the page. Without a focus trap, keyboard users can Tab behind the modal overlay and interact with elements they can't see, creating a confusing and broken experience.
A focus trap works by finding all focusable elements inside the container, then intercepting Tab and Shift+Tab keypresses at the boundaries. When the user presses Tab on the last focusable element, focus wraps to the first. When they press Shift+Tab on the first, focus wraps to the last.
function trapFocus(container) {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
];
const focusableElements = container.querySelectorAll(
focusableSelectors.join(', ')
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
function handleKeyDown(event) {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
// Shift+Tab: if on the first element, wrap to last
if (document.activeElement === firstFocusable) {
event.preventDefault();
lastFocusable.focus();
}
} else {
// Tab: if on the last element, wrap to first
if (document.activeElement === lastFocusable) {
event.preventDefault();
firstFocusable.focus();
}
}
}
container.addEventListener('keydown', handleKeyDown);
// Return a cleanup function
return () => container.removeEventListener('keydown', handleKeyDown);
}
The selector list targets all natively focusable elements plus any element with an explicit tabindex. Elements with tabindex="-1" are excluded because they are programmatically focusable but should not be part of the tab order. Disabled elements are also excluded because they cannot receive focus.
<dialog> element
The HTML <dialog> element, when opened with its showModal() method, provides built-in focus trapping. The browser handles Tab wrapping, Escape to close, and the inert backdrop automatically. Unless you need to support very old browsers, prefer <dialog> over a custom focus trap. See the Modal Dialog pattern for a complete implementation.
When using a custom focus trap, remember that the trap must be removed when the container is closed. Forgetting to clean up the keydown listener means the trap persists even after the modal is gone, which can cause unexpected behavior if the container is reopened or if other keydown handlers conflict.
// 03 · focus restoration
Focus Restoration
Focus restoration is the counterpart to focus trapping. When a modal, popover, or dropdown closes, focus should return to the element that triggered it. This gives keyboard and screen reader users a predictable experience — they press a button to open something, interact with it, close it, and find themselves right back where they started.
The pattern is straightforward: store a reference to the currently focused element before opening the overlay, then restore focus to it when closing.
class Dialog {
constructor(dialogElement) {
this.dialog = dialogElement;
this.triggerElement = null;
}
open() {
// Store the element that had focus before opening
this.triggerElement = document.activeElement;
this.dialog.showModal();
// Move focus into the dialog
const firstFocusable = this.dialog.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (firstFocusable) {
firstFocusable.focus();
}
}
close() {
this.dialog.close();
// Restore focus to the trigger element
if (this.triggerElement && this.triggerElement.isConnected) {
this.triggerElement.focus();
}
}
}
The key detail is the isConnected check. Before restoring focus, you need to verify that the trigger element is still in the DOM. If the element has been removed — for example, if the dialog was triggered from a list item that has since been deleted — calling focus() on a detached element does nothing, and focus falls to the <body>, effectively losing the user's place.
<body> silently.
close() {
this.dialog.close();
if (this.triggerElement && this.triggerElement.isConnected) {
// Ideal case: trigger still exists
this.triggerElement.focus();
} else if (this.fallbackElement && this.fallbackElement.isConnected) {
// Fallback: focus the next logical element
this.fallbackElement.focus();
} else {
// Last resort: focus the main content area
document.getElementById('main-content')?.focus();
}
}
<dialog> restores focus automatically
When you use <dialog> with showModal() and close it with close() or the Escape key, the browser automatically restores focus to the element that was focused before the dialog opened. You only need custom focus restoration when building custom overlays or when the trigger element may be removed from the DOM.
// 04 · roving tabindex
Roving Tabindex
Roving tabindex is a focus management pattern for composite widgets — components that contain multiple related interactive elements but should behave as a single tab stop. Tab panels, toolbars, menu bars, radio groups, and tree views all use this pattern. Instead of each item being a separate tab stop (which would require dozens of Tab presses to get through), the entire widget is a single tab stop, and arrow keys move between items inside it.
The technique works by setting tabindex="0" on the currently active item and tabindex="-1" on all other items. When the user presses an arrow key, you move tabindex="0" to the next item and call focus() on it.
<!-- HTML: only the active tab is in the tab order -->
<div role="tablist" aria-label="Settings">
<button role="tab" aria-selected="true" tabindex="0"
aria-controls="panel-general" id="tab-general">
General
</button>
<button role="tab" aria-selected="false" tabindex="-1"
aria-controls="panel-security" id="tab-security">
Security
</button>
<button role="tab" aria-selected="false" tabindex="-1"
aria-controls="panel-notifications" id="tab-notifications">
Notifications
</button>
</div>
// JavaScript: arrow key navigation with roving tabindex
const tablist = document.querySelector('[role="tablist"]');
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
tablist.addEventListener('keydown', (event) => {
const currentIndex = tabs.indexOf(document.activeElement);
let newIndex;
switch (event.key) {
case 'ArrowRight':
newIndex = (currentIndex + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return; // Don't prevent default for other keys
}
event.preventDefault();
// Move tabindex
tabs[currentIndex].setAttribute('tabindex', '-1');
tabs[newIndex].setAttribute('tabindex', '0');
tabs[newIndex].focus();
});
This approach gives keyboard users two levels of navigation: Tab moves between widgets on the page (form fields, buttons, the tablist), while arrow keys move between items within the widget. This matches how users expect composite widgets to behave in desktop applications.
Roving Tabindex vs aria-activedescendant
There are two approaches to managing focus within composite widgets. Roving tabindex moves actual DOM focus between items. aria-activedescendant keeps DOM focus on the container and uses an ARIA attribute to tell assistive technology which child is "active." Both are valid, but they have different trade-offs.
| Aspect | Roving Tabindex | aria-activedescendant |
|---|---|---|
| DOM focus location | Moves to each item | Stays on the container |
CSS :focus styling |
Works natively on each item | Requires custom styling based on attribute |
| Screen reader support | Excellent across all screen readers | Good but inconsistent in some older screen readers |
Works with contenteditable |
Not suitable (focus must stay in editable area) | Ideal (focus stays on the input) |
| Best for | Tabs, toolbars, radio groups, tree views | Comboboxes, listboxes, grid cells with input |
| Implementation complexity | Moderate — manage tabindex on each item | Moderate — manage attribute and scroll position |
aria-activedescendant only when you need DOM focus to remain on an input element, such as in a Combobox where the user types in a text field while arrowing through a listbox.
// 05 · programmatic focus
Programmatic Focus
Programmatic focus means using JavaScript to move focus with element.focus(). This is necessary whenever the page content changes in a way that leaves keyboard users stranded — after a route change in a single-page application, after dynamically loaded content appears, or after inline validation reveals an error message.
The most common situations where you need programmatic focus:
- SPA route changes — When a single-page app navigates to a new view, the URL changes but the page doesn't reload. Focus stays wherever it was (often a navigation link). Move focus to the new page's heading or main content area so screen reader users know the page has changed.
- Dynamic content loading — After loading new content (infinite scroll, "Load more" results, AJAX form submission), move focus to the beginning of the new content so keyboard users can interact with it.
- Inline validation errors — After form submission reveals validation errors, move focus to the first invalid field or to an error summary at the top of the form. Don't leave focus on the Submit button while error messages appear elsewhere on the page.
- Content removal — When a user deletes an item from a list, move focus to the next item, the previous item, or the list heading. Don't let focus disappear.
// SPA route change: focus the new page heading
function onRouteChange() {
const heading = document.querySelector('h1');
if (heading) {
// Make the heading programmatically focusable
heading.setAttribute('tabindex', '-1');
heading.focus();
// Optionally remove tabindex after blur
// so it doesn't appear in the tab order
heading.addEventListener('blur', () => {
heading.removeAttribute('tabindex');
}, { once: true });
}
}
// Form validation: focus the first invalid field
function onFormSubmit(form) {
const firstInvalid = form.querySelector('[aria-invalid="true"]');
if (firstInvalid) {
firstInvalid.focus();
}
}
// List item deletion: focus the next sibling
function onItemDelete(deletedItem) {
const nextItem = deletedItem.nextElementSibling;
const prevItem = deletedItem.previousElementSibling;
const list = deletedItem.parentElement;
deletedItem.remove();
if (nextItem) {
nextItem.querySelector('button, a, [tabindex]')?.focus();
} else if (prevItem) {
prevItem.querySelector('button, a, [tabindex]')?.focus();
} else {
// List is now empty, focus the list or a nearby heading
list.setAttribute('tabindex', '-1');
list.focus();
}
}
tabindex="-1" for non-interactive focus targets
Elements like headings, paragraphs, and container divs are not natively focusable. To focus them programmatically, add tabindex="-1". This makes the element focusable via JavaScript but keeps it out of the natural tab order. Screen readers will announce the element's content when it receives focus, orienting the user to the new context.
// 06 · focus indicators
Focus Indicators
A focus indicator is the visual outline or highlight that shows which element currently has keyboard focus. Without a visible focus indicator, keyboard users cannot tell where they are on the page. WCAG 2.2 introduced stricter requirements for focus appearance, and understanding these requirements is essential for building accessible interfaces.
WCAG 2.4.13 Focus Appearance (AAA)
WCAG 2.4.13 specifies that focus indicators must meet minimum size and contrast requirements. While this is a AAA criterion, the underlying principle — that focus must be clearly visible — applies at every conformance level. The requirements are:
- The focus indicator must have an area of at least 2 CSS pixels thick along the perimeter of the element
- The indicator must have a contrast ratio of at least 3:1 between its focused and unfocused states
- The indicator must not be entirely hidden by author-created content
The most reliable way to meet these requirements is with a solid outline offset from the element:
/* Base focus styles for all interactive elements */
:focus-visible {
outline: 2px solid var(--color-focus, #1a73e8);
outline-offset: 2px;
}
/* Remove the default outline only when :focus-visible is supported */
:focus:not(:focus-visible) {
outline: none;
}
/* High contrast mode support */
@media (forced-colors: active) {
:focus-visible {
outline: 2px solid Highlight;
}
}
The :focus-visible pseudo-class is the modern solution for showing focus indicators only when they're useful. Browsers apply :focus-visible when the user is navigating with a keyboard (Tab, arrow keys) but not when they click with a mouse. This means keyboard users see the outline, while mouse users don't — the best of both worlds.
outline: none without a replacement
The single most common focus indicator mistake is adding *:focus { outline: none; } or a:focus, button:focus { outline: none; } to a stylesheet to remove the "ugly" default browser outline. This removes the only way keyboard users can tell where they are on the page. If you must customize the focus indicator, always provide an alternative: a custom outline, a box-shadow, a border change, or a background color change. The replacement must meet the 3:1 contrast requirement.
/* Good: custom focus style that replaces the default */
.btn:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(0, 95, 204, 0.3);
}
/* Good: focus style for dark backgrounds */
.dark-section a:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
/* Bad: removes focus indicator with no replacement */
/* button:focus { outline: none; } -- NEVER DO THIS */
outline-offset helps by creating a gap between the element and the outline, making it visible regardless of the element's background color.
// 07 · common patterns summary
Common Patterns Summary
Use this quick reference table to determine which focus management pattern applies to your use case.
| Pattern | When to Use | Key Technique |
|---|---|---|
| Focus Trapping | Modal dialogs, full-screen overlays, alert dialogs | Intercept Tab/Shift+Tab at container boundaries; use <dialog> with showModal() |
| Focus Restoration | Closing modals, popovers, dropdown menus, tooltips | Store document.activeElement before open; restore on close with isConnected check |
| Roving Tabindex | Tabs, toolbars, menu bars, radio groups, tree views | tabindex="0" on active item, tabindex="-1" on rest; arrow key navigation |
aria-activedescendant |
Comboboxes, listboxes, grids with editable cells | Keep DOM focus on container; update aria-activedescendant to point to active child ID |
| Programmatic Focus | SPA route changes, dynamic content loads, form validation errors | element.focus() with tabindex="-1" on non-interactive targets |
| Focus Indicators | All interactive elements at all times | :focus-visible with 2px outline, 3:1 contrast, and outline-offset |
aria-activedescendant (to navigate options), programmatic focus (to focus the input on open), and focus restoration (to return focus after selection). Understanding each pattern individually makes it easier to combine them correctly.
// 08 · next steps
Next Steps
Focus management is invisible when it works and glaring when it doesn't — the only way to be sure is to test. Navigate every flow with the keyboard (see the keyboard testing guide), then verify your implementation against the Developer Accessibility Checklist, which includes the focus-order and no-keyboard-trap checks this guide explains.