Focus Management
Focus traps, skip links, and focus restoration for accessible keyboard navigation.
Core Patterns
1. Focus Traps (Modal Dialogs)
Focus traps prevent keyboard users from tabbing out of modals by cycling between the first and last focusable elements. When a user presses Tab on the last element, focus returns to the first; Shift+Tab wraps backwards.
function createFocusTrap(container) {
const focusable = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
first.focus(); // Move focus into trap
}
For production, use the focus-trap library — it handles dynamic content, iframes, Shadow DOM, and return focus automatically.
2. Skip Links
Skip links are hidden navigation shortcuts that become visible on keyboard focus, allowing users to bypass repetitive navigation (WCAG Level A — 2.4.1 Bypass Blocks).
<!-- Place as the very first element in <body> -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>
<nav><!-- navigation links --></nav>
</header>
<main id="main-content" tabindex="-1">
<!-- primary content -->
</main>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
3. Focus Restoration
When a modal closes, focus must return to the element that opened it.
class Modal {
open() {
this.previousFocus = document.activeElement; // Save
this.show();
this.firstFocusableElement.focus(); // Move into modal
}
close() {
this.hide();
this.previousFocus?.focus(); // Restore
}
}
// React: useRef for focus restoration
function Modal({ isOpen, onClose, children }) {
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement;
} else {
previousFocusRef.current?.focus();
}
}, [isOpen]);
// ...
}
tabindex Values
| Value | Behavior | Use When |
|---|---|---|
tabindex="0" | Adds to natural tab order | Making non-interactive elements focusable |
tabindex="-1" | Focusable only programmatically | Modal containers, skip-link targets |
tabindex="1+" | Avoid — disrupts natural order | Almost never |
Roving Tabindex Pattern
For complex widgets (toolbars, menus, grids), restrict Tab to a single entry point and use arrow keys for internal navigation.
class RovingTabindex {
constructor(container) {
this.items = Array.from(container.querySelectorAll('[role="tab"]'));
this.currentIndex = 0;
this.items.forEach((item, i) => {
item.tabIndex = i === 0 ? 0 : -1;
item.addEventListener('keydown', (e) => this.handleKey(e, i));
});
}
handleKey(event, index) {
let newIndex = index;
if (event.key === 'ArrowRight') newIndex = (index + 1) % this.items.length;
if (event.key === 'ArrowLeft') newIndex = (index - 1 + this.items.length) % this.items.length;
if (event.key === 'Home') newIndex = 0;
if (event.key === 'End') newIndex = this.items.length - 1;
if (newIndex !== index) {
event.preventDefault();
this.items.forEach((item, i) => item.tabIndex = i === newIndex ? 0 : -1);
this.items[newIndex].focus();
}
}
}
Critical Rule: Never Remove Focus Without Replacement
/* ❌ Very bad: removes all focus indicators */
* { outline: none; }
/* ✅ Good: replace with custom indicator */
*:focus-visible {
outline: 3px solid #0078d4;
outline-offset: 2px;
}
Testing: The No-Mouse Challenge
- Put your mouse away
- Tab through your entire site
- Verify:
- You can see where focus is at all times
- All interactive elements are reachable
- Modals trap focus correctly
- Escape closes dialogs
- Focus restores after dialogs close
Content from Frontend-Master-Prep-Series — 08-accessibility