Skip to main content
Saved

Focus Trap

Category Accessibility

Level Beginner

Examples

Vue, Native web

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

In this article
  1. Problem
  2. Solution
  3. Example
  4. Basic Focus Trap Implementation
  5. Focus Trap with Background Inert
  6. Using focus-trap Library
  7. Benefits
  8. Tradeoffs
  9. Summary
  10. See also

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 moves past the overlay even though the modal visually blocks the background.

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

A focus trap intercepts Tab and Shift+Tab keypresses. When focus is on the last focusable element and the user presses Tab, focus moves back to the first element, and when focus is on the first element and the user presses Shift+Tab, focus moves to the last. Focus then cycles through the modal and never reaches the background content.

The implementation finds all focusable elements inside your modal (buttons, links, form inputs, and elements with tabindex="0"), tracks which are first and last in focus order, and listens for Tab keypresses to redirect focus when needed.

aria-modal="true" tells screen readers the background is inert, and adding the inert attribute to the main content behind the modal removes that content from keyboard and screen reader access entirely. A native <dialog> opened with showModal() makes the rest of the page inert by itself, so it doesn’t need a hand-written trap.

The modal also needs an obvious exit: the Escape key closes it, and there’s a visible close button.

Example

Basic Focus Trap Implementation

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

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

    const focusableElements = modal.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();
    };

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

    return () => modal.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. The modal renders through a portal outside #root, so it isn’t made inert along with the background:

import { createPortal } from 'react-dom';
import { FocusTrap } from 'focus-trap-react';

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 createPortal(
    <FocusTrap focusTrapOptions={{ escapeDeactivates: false }}>
      <div role="dialog" aria-modal="true" onKeyDown={(e) => e.key === 'Escape' && onClose()}>
        <h2>Modal Title</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </FocusTrap>,
    document.body
  );
}

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: false, // Escape closes the modal below, which unmounts the trap
      returnFocusOnDeactivate: true
    }}>
      <div role="dialog" aria-modal="true" onKeyDown={(e) => e.key === 'Escape' && onClose()}>
        <h2 id="modal-title" tabIndex={-1}>Modal Title</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </FocusTrap>
  );
}

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

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, so they don’t press buttons hidden behind the overlay.
  • The WAI-ARIA Authoring Practices dialog pattern keeps focus inside the modal, so this is the expected behavior for accessible overlays.
  • Users stay inside the modal instead of getting lost in background content they can’t see.

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 the list of focusable elements needs re-querying when content changes.
  • A focus trap without an obvious exit frustrates users, so Escape key handling and a visible close button both need to be there.
  • 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. aria-modal="true" signals that the background is inactive, but how that’s handled varies by browser.
  • The inert attribute disables background content properly and is now supported in all major browsers, which makes 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.
  • Automated tests can’t check whether the experience feels natural to users, so testing a focus trap properly needs real assistive technology.

Summary

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

See also

Newsletter

A Monthly Email
from Den Odell

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

No spam. Unsubscribe anytime.