Saved
Frontend Pattern

Focus Trap

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

Difficulty Beginner

By Den Odell

Focus Trap

Problem

Keyboard users can tab out of modal dialogs and interact with content behind the overlay that’s visually hidden or dimmed. Focus escapes the modal boundary, reaching buttons and links in the background that appear inaccessible but remain in the tab order, forcing users to tab through all background content to eventually cycle back to the modal, breaking the modal’s purpose of focused interaction and creating confusion about which UI elements are currently active. Screen readers announce content from behind the modal overlay even though users can’t see it, creating a mismatch between visual and semantic presentation, while users pressing Tab repeatedly to find the modal’s close button accidentally activate background controls instead. The Escape key may not close the modal, leaving keyboard users without a clear exit path, and although mouse users see clear visual separation between modal foreground and dimmed background, keyboard users experience no such boundary without proper focus trapping. Complex modals with nested focusable elements become navigation mazes where users lose track of whether they’re still inside the modal or have escaped to background content.

Solution

Contain keyboard navigation within modals or dialogs by intercepting Tab and Shift+Tab key presses and cycling focus between the first and last focusable elements in the modal. Query for all focusable elements (buttons, links, form inputs, elements with tabindex ≥ 0) within the modal container. When focus reaches the last element and user presses Tab, move focus back to the first element. When focus is on the first element and user presses Shift+Tab, move focus to the last element. This creates a circular tab order that keeps keyboard users engaged with the active context. Set aria-modal="true" on the dialog to inform assistive technologies that content outside the modal is inert. Provide escape mechanisms like Escape key handler and clearly visible close button so users aren’t permanently trapped. Remove or disable background content from the tab order by setting inert attribute or aria-hidden="true" on the main content element. This keeps keyboard users engaged with the active context and matches how these overlays trap mouse interaction visually.

Example

This example demonstrates trapping keyboard focus within a modal by cycling between the first and last focusable elements.

Basic Focus Trap Implementation

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

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

    // Query all focusable elements
    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') {
        // Shift+Tab at first element: cycle to last
        if (e.shiftKey && document.activeElement === firstElement) {
          lastElement.focus();
          e.preventDefault();
        // Tab at last element: cycle to first
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          firstElement.focus();
          e.preventDefault();
        }
      }

      // Escape key closes modal
      if (e.key === 'Escape') {
        onClose();
      }
    };

    // Add keyboard listener
    modalRef.current.addEventListener('keydown', trapFocus);

    // Focus first element when modal opens
    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

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

  useEffect(() => {
    if (isOpen) {
      // Save previously focused element
      previousFocusRef.current = document.activeElement;

      // Mark background content as inert
      const mainContent = document.querySelector('#root');
      mainContent?.setAttribute('inert', '');
      mainContent?.setAttribute('aria-hidden', 'true');
    } else {
      // Remove inert state from background
      const mainContent = document.querySelector('#root');
      mainContent?.removeAttribute('inert');
      mainContent?.removeAttribute('aria-hidden');

      // Restore focus
      previousFocusRef.current?.focus();
    }

    return () => {
      const mainContent = document.querySelector('#root');
      mainContent?.removeAttribute('inert');
      mainContent?.removeAttribute('aria-hidden');
    };
  }, [isOpen]);

  if (!isOpen) return null;

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

Using focus-trap Library

import FocusTrap from 'focus-trap-react';

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

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

Vue Focus Trap

<template>
  <div
    v-if="isOpen"
    ref="modalRef"
    role="dialog"
    aria-modal="true"
    @keydown="handleKeydown"
  >
    <h2 ref="firstFocusable" tabindex="-1">Modal Title</h2>
    <div>
      <slot />
    </div>
    <button ref="lastFocusable" @click="$emit('close')">
      Close
    </button>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';

const props = defineProps(['isOpen']);
const emit = defineEmits(['close']);

const modalRef = ref(null);
const firstFocusable = ref(null);
const lastFocusable = ref(null);

const handleKeydown = (e) => {
  if (e.key === 'Escape') {
    emit('close');
    return;
  }

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

    if (e.shiftKey && document.activeElement === firstElement) {
      lastElement.focus();
      e.preventDefault();
    } else if (!e.shiftKey && document.activeElement === lastElement) {
      firstElement.focus();
      e.preventDefault();
    }
  }
};

watch(() => props.isOpen, (isOpen) => {
  if (isOpen) {
    firstFocusable.value?.focus();
  }
});
</script>

Web Component Focus Trap

class FocusTrapDialog extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.render();
    this.setupFocusTrap();
  }

  setupFocusTrap() {
    const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
    
    this.addEventListener('keydown', (e) => {
      const focusableElements = this.shadowRoot.querySelectorAll(focusableSelector);
      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      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') {
        this.close();
      }
    });

    // Focus first element
    const firstFocusable = this.shadowRoot.querySelector(focusableSelector);
    firstFocusable?.focus();
  }

  close() {
    this.dispatchEvent(new CustomEvent('close'));
  }

  render() {
    this.shadowRoot.innerHTML = `
      <div role="dialog" aria-modal="true">
        <h2>Dialog Title</h2>
        <slot></slot>
        <button id="close-btn">Close</button>
      </div>
    `;

    this.shadowRoot.getElementById('close-btn')
      .addEventListener('click', () => this.close());
  }
}

customElements.define('focus-trap-dialog', FocusTrapDialog);

Nested Focus Traps

function ConfirmationDialog({ onConfirm, onCancel }) {
  return (
    <FocusTrap>
      <div role="alertdialog" aria-modal="true">
        <h2>Are you sure?</h2>
        <p>This action cannot be undone.</p>
        <button onClick={onConfirm}>Confirm</button>
        <button onClick={onCancel}>Cancel</button>
      </div>
    </FocusTrap>
  );
}

function MainModal({ onClose }) {
  const [showConfirmation, setShowConfirmation] = useState(false);

  return (
    <FocusTrap active={!showConfirmation}>
      <div role="dialog" aria-modal="true">
        <h2>Main Modal</h2>
        <button onClick={() => setShowConfirmation(true)}>
          Delete Item
        </button>
        <button onClick={onClose}>Close</button>

        {showConfirmation && (
          <ConfirmationDialog
            onConfirm={() => {
              deleteItem();
              setShowConfirmation(false);
            }}
            onCancel={() => setShowConfirmation(false)}
          />
        )}
      </div>
    </FocusTrap>
  );
}

Focus Trap with Initial Focus

function SearchModal({ onClose }) {
  const searchInputRef = useRef();

  return (
    <FocusTrap
      focusTrapOptions={{
        initialFocus: () => searchInputRef.current,
        returnFocusOnDeactivate: true
      }}
    >
      <div role="dialog" aria-modal="true">
        <h2>Search</h2>
        <input
          ref={searchInputRef}
          type="search"
          placeholder="Search..."
        />
        <button onClick={onClose}>Close</button>
      </div>
    </FocusTrap>
  );
}

Accessible Overlay

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

  return (
    <>
      {/* Overlay prevents mouse clicks on background */}
      <div
        className="modal-overlay"
        onClick={onClose}
        aria-hidden="true"
      />

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

Conditional Focus Trap

function Sidebar({ isOpen }) {
  // Only trap focus when sidebar is in overlay mode (mobile)
  const shouldTrapFocus = useMediaQuery('(max-width: 768px)');

  const content = (
    <nav>
      <a href="/">Home</a>
      <a href="/about">About</a>
      <button onClick={onClose}>Close</button>
    </nav>
  );

  if (shouldTrapFocus && isOpen) {
    return <FocusTrap>{content}</FocusTrap>;
  }

  return content;
}

Benefits

  • Improves accessibility by preventing keyboard users from accidentally accessing hidden content behind modals that’s visually obscured.
  • Creates consistent interaction model where focus behavior matches visual presentation - if content looks unreachable, it behaves as unreachable.
  • Meets WCAG 2.1 requirements for modal dialogs (Success Criterion 2.4.3) and overlay components that block interaction with background content.
  • Reduces user confusion by keeping focus within the active context, making it clear which UI elements are currently operable.
  • Prevents accidental activation of background controls that could have unintended consequences like data loss or navigation away from unsaved work.
  • Matches mouse interaction patterns where clicking background is blocked by overlay, creating consistent behavior across input methods.

Tradeoffs

  • Requires tracking first and last focusable elements dynamically as modal content changes, adding implementation complexity especially for modals with conditional content.
  • Can trap users permanently if the modal lacks clear escape methods like Escape key handler, visible close button, or click-outside-to-close behavior.
  • May conflict with browser extensions or assistive technology that manage focus independently, potentially creating focus ping-pong where focus jumps between extension UI and modal.
  • Needs careful testing across different browsers and assistive technologies - focus trap behavior varies between NVDA, JAWS, VoiceOver, and between Firefox, Chrome, and Safari.
  • Determining focusable elements is complex - must handle disabled elements, negative tabindex, visibility hidden/display none, and custom focusable elements with ARIA.
  • Nested modals (confirmation dialog within settings modal) require careful management to determine which focus trap is active and coordinate transitions between traps.
  • Dynamic content changes inside modal (loading states, conditional fields) require re-querying focusable elements or the focus trap breaks when elements appear/disappear.
  • Libraries like focus-trap add bundle size and external dependencies, while custom implementations require significant code and edge case handling.
  • inert attribute for background content has limited browser support, requiring polyfills or fallbacks to aria-hidden which doesn’t prevent mouse clicks.
  • Focus traps can interfere with browser debugging tools, DevTools panels, and developer workflows during development, requiring temporary disabling.
  • Screen reader virtual cursor can escape focus trap since it operates independently from DOM focus, requiring aria-modal to truly hide background content from screen readers.
  • Escape key handling must be implemented separately from focus trap itself and may conflict with other escape key handlers in the application.
  • Click-outside-to-close behavior conflicts with focus trap philosophy of containing interaction but users expect it, requiring careful implementation to handle both patterns.
  • Testing focus traps in automated tests is difficult - requires simulating Tab key presses and verifying focus position which is flaky across test environments.
Stay Updated

Get New Patterns
in Your Inbox

Join thousands of developers receiving regular insights on frontend architecture patterns

No spam. Unsubscribe anytime.