{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-focus-trap", "type": "registry:hook", "title": "useFocusTrap", "description": "Trap focus inside an overlay while it is open, and restore it to the trigger on close.", "categories": [ "hooks", "accessibility" ], "files": [ { "path": "hooks/use-focus-trap.ts", "type": "registry:hook", "target": "hooks/use-focus-trap.ts", "content": "import { useEffect, type RefObject } from \"react\";\n\nconst FOCUSABLE_SELECTOR = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"textarea:not([disabled])\",\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\n/**\n * Traps Tab focus within `ref` while `active`, moving focus into the container\n * on activate and restoring it to the previously-focused element on deactivate.\n * Use for modal dialogs and menus (BottomDrawer, the MobileBottomNav menu).\n *\n * The container should be focusable as a fallback (give it `tabIndex={-1}`) for\n * the case where it has no focusable children yet.\n *\n * `initialFocus` controls where focus lands on activate:\n * - `'first'` (default): the first focusable child -- right for menus, where\n * the user is about to act on an item.\n * - `'container'`: the container itself -- right for form drawers, so opening\n * one does NOT auto-focus a text field and pop the mobile keyboard (which\n * obscures the rest of the form). The container still receives focus for\n * screen readers; Tab then reaches the fields.\n */\nexport function useFocusTrap(\n ref: RefObject,\n active: boolean,\n options?: { initialFocus?: \"first\" | \"container\" },\n): void {\n const initialFocus = options?.initialFocus ?? \"first\";\n useEffect(() => {\n if (!active) return;\n const container = ref.current;\n if (!container) return;\n\n const previouslyFocused = document.activeElement as HTMLElement | null;\n\n const focusable = () =>\n Array.from(\n container.querySelectorAll(FOCUSABLE_SELECTOR),\n ).filter((el) => el.offsetParent !== null || el === document.activeElement);\n\n // Move focus into the container.\n if (initialFocus === \"container\") {\n container.focus();\n } else {\n (focusable()[0] ?? container).focus();\n }\n\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key !== \"Tab\") return;\n const items = focusable();\n if (items.length === 0) {\n e.preventDefault();\n return;\n }\n const first = items[0];\n const last = items[items.length - 1];\n if (e.shiftKey && document.activeElement === first) {\n e.preventDefault();\n last.focus();\n } else if (!e.shiftKey && document.activeElement === last) {\n e.preventDefault();\n first.focus();\n }\n };\n\n document.addEventListener(\"keydown\", onKeyDown, true);\n return () => {\n document.removeEventListener(\"keydown\", onKeyDown, true);\n // Restore focus to whatever was focused before the trap activated.\n previouslyFocused?.focus?.();\n };\n }, [active, ref, initialFocus]);\n}\n" } ], "docs": "BottomDrawer uses this already. For a custom overlay, pass initialFocus: 'container' when the overlay holds a multi-field form, so opening it does not focus a text field and pop the mobile keyboard. Tab still reaches every field.", "meta": { "group": "hooks", "related": [ "bottom-drawer", "use-route-focus" ], "exports": [ "useFocusTrap" ], "siteSlug": "use-focus-trap" } }