Skip to content
Back to Design Hub

Focus Trap for Modals & Drawers

Constraining Tab cycle inside an overlay and restoring focus on close.

AccessibilityIntermediate

Why

Without a focus trap, Tab inside a modal escapes to the page behind it — keyboard users interact with invisible content. Trapping focus keeps them in the modal until they explicitly close it.

The WebAIM Million 2024 report found 96.3% of homepages had detectable WCAG failures. ADA-related lawsuits exceeded 4,500 in 2023, making accessibility a legal and ethical necessity, not a nice-to-have.

When

Use a focus trap for every modal, dialog, drawer, and popover that overlays the page. Skip for inline disclosures — they're part of the page flow.

How

1. On open — save document.activeElement as the restore target, move focus to the modal's first focusable 2. Trap — on Tab, if at last focusable and pressing Tab, cycle to first; Shift+Tab at first cycles to last 3. Restore — on close, call .focus() on the saved target (the button that opened the modal) 4. Library — focus-trap-react (3KB) handles edge cases; or Radix Dialog / Headless UI Dialog 5. Initial focus — focus the modal title or first input, not the container (container needs tabindex=-1) 6. Guard — ignore clicks outside the modal (or close on outside click), but never let focus leave 7. Nested — a second trap stacks; closing the inner restores to the outer, not the page

Example

import { FocusTrap } from "focus-trap-react"; function Modal({ open, onClose, title, children }: Props) { const restoreRef = useRef<HTMLElement | null>(null); useEffect(() => { if (!open) return; restoreRef.current = document.activeElement as HTMLElement; return () => restoreRef.current?.focus(); // restore on close }, [open]); if (!open) return null; return ( <FocusTrap> <div role="dialog" aria-modal="true" aria-labelledby="t"> <h2 id="t" tabIndex={-1}>{title}</h2> {children} <button onClick={onClose}>Close</button> </div> </FocusTrap> ); } // Or hand-rolled: query focusables, handle Tab/Shift+Tab at boundaries const focusables = (el: HTMLElement) => el.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');

Common Problems

  • ⚠No focus restore — modal closes, focus lands on body, screen-reader user lost
  • ⚠Trap misses dynamically added focusables — Tab escapes after a form field renders
  • ⚠Initial focus on the container — screen reader announces the div, not the dialog's purpose

Best Practices

  • ✓Always restore focus on close — return to the button that opened the modal, not document.body
  • ✓Use a maintained library (focus-trap-react, Radix Dialog) — handles dynamic focusables + nested traps
  • ✓Focus the title or first input on open — gives screen-reader users immediate context

FAQ