Skip to main content
Saved
Pattern
Difficulty Beginner

Focus Trap

Constrain keyboard focus within modal dialogs and overlays to prevent focus from escaping to background content.

By Den Odell Added

Focus Trap

Problem

Mouse users see the dimmed overlay behind a modal and understand they should interact with the modal content, but keyboard users press Tab and find themselves activating buttons beneath the overlay. Keyboard focus escapes right through it even though the modal looks like it’s blocking the background visually.

I’ve watched users Tab around a modal, past the close button, into the page footer, through the entire navigation menu, and all the way back around before finally landing on the modal content. It’s as if the modal doesn’t exist for keyboard navigation.

Screen readers make this worse by announcing background content to users who can’t see the overlay, creating a mismatch between what users see and what assistive technology allows.

Solution

Trap focus by intercepting Tab and Shift+Tab keypresses. When focus reaches the last focusable element and the user presses Tab, move focus back to the first element; when focus is on the first element and the user presses Shift+Tab, move focus to the last. This creates a continuous cycle that never escapes to background content.

To implement this, find all focusable elements inside your modal (buttons, links, form inputs, and elements with tabindex="0"), then track which are first and last in focus order and listen for Tab keypresses to redirect focus when needed.

Add aria-modal="true" to tell screen readers the background is inert, and consider adding the inert attribute to main content behind the modal to fully disable it from keyboard and screen reader access.

Always provide an obvious exit: listen for Escape to close the modal and include a visible close button. A focus trap with no exit just frustrates users.

Example

Basic Focus Trap Implementation

function Modal({ children, onClose }) {
  const modalRef = useRef();

  useEffect(() => {
    if (!modalRef.current) return;

    const focusableElements = modalRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    const trapFocus = (e) => {
      if (e.key === 'Tab') {
        if (e.shiftKey && document.activeElement === firstElement) {
          lastElement.focus();
          e.preventDefault();
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          firstElement.focus();
          e.preventDefault();
        }
      }
      if (e.key === 'Escape') onClose();
    };

    modalRef.current.addEventListener('keydown', trapFocus);
    firstElement?.focus();

    return () => modalRef.current?.removeEventListener('keydown', trapFocus);
  }, [onClose]);

  return (
    <div ref={modalRef} role="dialog" aria-modal="true">
      {children}
    </div>
  );
}

Focus Trap with Background Inert

Using the inert attribute disables the background content entirely, preventing both focus and screen reader access:

function Modal({ isOpen, onClose, children }) {
  const previousFocusRef = useRef();

  useEffect(() => {
    const mainContent = document.querySelector('#root');
    if (isOpen) {
      previousFocusRef.current = document.activeElement;
      mainContent?.setAttribute('inert', '');
      mainContent?.setAttribute('aria-hidden', 'true');
    } else {
      mainContent?.removeAttribute('inert');
      mainContent?.removeAttribute('aria-hidden');
      previousFocusRef.current?.focus();
    }
    return () => {
      mainContent?.removeAttribute('inert');
      mainContent?.removeAttribute('aria-hidden');
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <FocusTrap>
      <div role="dialog" aria-modal="true">
        <h2>Modal Title</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </FocusTrap>
  );
}

Using focus-trap Library

The focus-trap-react library handles edge cases like dynamic content and nested traps automatically.

import FocusTrap from 'focus-trap-react';

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;

  return (
    <FocusTrap focusTrapOptions={{
      initialFocus: '#modal-title',
      escapeDeactivates: true,
      returnFocusOnDeactivate: true
    }}>
      <div role="dialog" aria-modal="true">
        <h2 id="modal-title">Modal Title</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </FocusTrap>
  );
}

For nested modals like confirmation dialogs inside settings panels, set active={false} on the parent trap when the child opens to transfer focus control.

Benefits

  • Keyboard behavior matches visual behavior. If content looks unreachable behind an overlay, it behaves as unreachable for keyboard navigation too.
  • Users can’t accidentally trigger actions on hidden background content, eliminating frustrating interactions with obscured buttons.
  • WCAG requires focus trapping for modal dialogs, so this pattern is mandatory for accessible overlays.
  • Users stay oriented within the modal context instead of getting lost navigating invisible background content.

Tradeoffs

  • Tracking focusable elements is complex. You need to handle disabled elements, tabindex="-1", hidden elements, and collapsed containers. Dynamic content breaks traps when elements appear or disappear, so you must re-query on content changes.
  • A focus trap without an obvious exit frustrates users. Always implement Escape key handling and include a visible close button.
  • Nested modals require careful coordination of which trap is active and which should be suspended when child dialogs open.
  • Screen reader virtual cursors can sometimes escape the trap. While aria-modal="true" signals the background is inactive, behavior varies by browser.
  • The inert attribute properly disables background content and now has full browser support, making it the preferred approach for new implementations.
  • Libraries like focus-trap handle edge cases but add to your bundle; custom implementations require handling all edge cases yourself.
  • Testing focus trap behavior properly requires real assistive technology. Automated tests can’t verify the experience feels natural to actual users.

Summary

Focus traps constrain keyboard focus within a container, preventing users from tabbing out of modals and overlays. When a modal opens, trap focus inside it; when it closes, return focus to the triggering element. This keeps keyboard and screen reader users oriented and prevents accidental interactions with obscured background content.

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.