Skip to main content

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.

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

ValueBehaviorUse When
tabindex="0"Adds to natural tab orderMaking non-interactive elements focusable
tabindex="-1"Focusable only programmaticallyModal containers, skip-link targets
tabindex="1+"Avoid — disrupts natural orderAlmost 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

  1. Put your mouse away
  2. Tab through your entire site
  3. 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-Series08-accessibility