← Back to all hooks

useFocusTrap

Trap keyboard focus within an element. Essential for accessible modals, dialogs, and menus.

Live Demo

⌨️ Keyboard Navigation Test:

  • Open a modal and press Tab to cycle through focusable elements
  • Press Shift+Tab to cycle backwards
  • Notice how focus stays trapped within the modal
  • Try to focus elements outside the modal - you can't!

📝 Event Log

No events yet. Open a modal to test focus trapping!

Code Examples

Basic Modal with Focus Trap

import { useFocusTrap } from "@uiblock/hooks";

function Modal({ isOpen, onClose }) {
  const trapRef = useFocusTrap({ enabled: isOpen });

  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div ref={trapRef} className="modal-content">
        <h2>Modal Title</h2>
        <input placeholder="First input" />
        <input placeholder="Second input" />
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

With Options

import { useFocusTrap } from "@uiblock/hooks";

function Dialog({ isOpen, onClose }) {
  const trapRef = useFocusTrap({
    enabled: isOpen,
    autoFocus: true,        // Focus first element on mount
    restoreFocus: true,     // Restore focus on unmount
    allowEscape: false      // Prevent Escape key from breaking trap
  });

  if (!isOpen) return null;

  return (
    <div ref={trapRef}>
      <h2>Dialog</h2>
      <button>Action 1</button>
      <button>Action 2</button>
      <button onClick={onClose}>Close</button>
    </div>
  );
}

Accessible Form Dialog

import { useFocusTrap } from "@uiblock/hooks";

function FormDialog({ isOpen, onSubmit, onCancel }) {
  const trapRef = useFocusTrap({ enabled: isOpen });

  if (!isOpen) return null;

  return (
    <div ref={trapRef} role="dialog" aria-modal="true">
      <h2>Enter Information</h2>
      <form onSubmit={onSubmit}>
        <input name="name" placeholder="Name" />
        <input name="email" type="email" placeholder="Email" />
        <button type="submit">Submit</button>
        <button type="button" onClick={onCancel}>Cancel</button>
      </form>
    </div>
  );
}

How It Works

Here's the implementation:

import { useEffect, useRef } from 'react'

const FOCUSABLE_ELEMENTS = [
  'a[href]',
  'button:not([disabled])',
  'textarea:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  '[tabindex]:not([tabindex="-1"])'
].join(', ')

export function useFocusTrap(options = {}) {
  const { enabled = true, autoFocus = true, restoreFocus = true, allowEscape = false } = options

  const ref = useRef(null)
  const previouslyFocusedElement = useRef(null)

  useEffect(() => {
    if (!enabled) return

    const element = ref.current
    if (!element) return

    // Store previously focused element
    previouslyFocusedElement.current = document.activeElement

    // Get all focusable elements
    const getFocusableElements = () => {
      if (!element) return []
      return Array.from(element.querySelectorAll(FOCUSABLE_ELEMENTS))
    }

    // Focus first element if autoFocus is enabled
    if (autoFocus) {
      const focusableElements = getFocusableElements()
      if (focusableElements.length > 0) {
        focusableElements[0].focus()
      }
    }

    // Handle tab key
    const handleKeyDown = (event) => {
      if (event.key !== 'Tab' && (!allowEscape || event.key !== 'Escape')) {
        return
      }

      if (allowEscape && event.key === 'Escape') {
        return
      }

      const focusableElements = getFocusableElements()
      if (focusableElements.length === 0) return

      const firstElement = focusableElements[0]
      const lastElement = focusableElements[focusableElements.length - 1]

      // Shift + Tab
      if (event.shiftKey) {
        if (document.activeElement === firstElement) {
          event.preventDefault()
          lastElement.focus()
        }
      }
      // Tab
      else {
        if (document.activeElement === lastElement) {
          event.preventDefault()
          firstElement.focus()
        }
      }
    }

    element.addEventListener('keydown', handleKeyDown)

    return () => {
      element.removeEventListener('keydown', handleKeyDown)

      // Restore focus to previously focused element
      if (restoreFocus && previouslyFocusedElement.current) {
        previouslyFocusedElement.current.focus()
      }
    }
  }, [enabled, autoFocus, restoreFocus, allowEscape])

  return ref
}

Key Features:

  • Traps focus within the container element
  • Auto-focuses first element on mount
  • Restores focus to previous element on unmount
  • Handles Tab and Shift+Tab navigation
  • Essential for accessible modals and dialogs

API Reference

Options

enabled?: boolean

Whether the focus trap is enabled (default: true)

autoFocus?: boolean

Focus the first element on mount (default: true)

restoreFocus?: boolean

Restore focus to previously focused element on unmount (default: true)

allowEscape?: boolean

Allow Escape key to break the trap (default: false)

returns: RefObject

Ref to attach to the container element

💡 Use Cases

  • Modal dialogs (accessibility requirement)
  • Dropdown menus
  • Alert dialogs
  • Form wizards
  • Sidebar navigation
  • Any overlay that requires keyboard navigation