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-Series — 08-accessibility