Docs
useFocusTrap

useFocusTrap

Manual focus trap for modal surfaces (WCAG 2.4.3 / 4.1.2). While active it moves focus into the container, wraps Tab/Shift+Tab at the edges, fires onEscape on Escape, and returns focus to the previously-focused element on release. Modal uses it; Sheet-like overlays should too.

The trap is deliberately manual (no injected guard nodes): it adds nothing to the DOM, so visual baselines and layout stay untouched.

When to use

✅ Use when…🚫 Avoid when…
  • Modal overlays that make the rest of the page inert: dialogs, sheets, drawers.
  • Any surface with role="dialog" + aria-modal="true".
  • Non-modal popups (menus, tooltips, popovers) — focus should be free to leave them.
  • Surfaces that are not yet in the DOM — gate active on the element actually being mounted.

Signature

useFocusTrap(ref: React.RefObject<HTMLElement | null>, opt: {
  active: boolean;
  onEscape?: () => void;
});
  • ref — the container to trap focus inside. Give it tabIndex={-1} so it can receive initial focus when it holds no interactive element.
  • opt.active — engage/release. Gate it on the surface being mounted (e.g. isOpen && presence when paired with useExitTransition).
  • opt.onEscape — close callback; the Escape event is stopped so outer layers don't also react.

Example

function Dialog({ open, onClose, children }) {
  const panelRef = useRef<HTMLDivElement>(null);
  const { mounted, state, ref } = useExitTransition(open);
 
  useFocusTrap(panelRef, { active: open && mounted, onEscape: onClose });
 
  if (!mounted) return null;
  return (
    <div
      ref={(node) => {
        panelRef.current = node;
        ref(node);
      }}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}
      data-state={state}
    >
      {children}
    </div>
  );
}

See also

  • useExitTransition — pairs with the trap for animated open/close.
  • Modal — the reference consumer.