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