useFocusTrap
A hook that keeps keyboard focus inside a container — moves focus in, cycles Tab, defers Escape to you, and returns focus to the trigger.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-focus-trap.jsonPrompt
Build a React + TypeScript "useFocusTrap" hook (React only, no other
dependencies; DOM APIs only).
Contract
- `useFocusTrap({ active, initialFocus?, returnFocus = true, onEscape? }):
{ ref }` where `ref` is a callback ref the consumer puts on the container:
`const { ref } = useFocusTrap({ active: open })` … `<div ref={ref}>`.
- `active: boolean` — flipping it true arms the trap and moves focus in;
flipping it false, or unmounting the container, disarms it and restores
focus.
- `initialFocus?: RefObject<HTMLElement | null> | string | null` — where focus
should land. A string is a CSS selector resolved *inside* the container, so
a consumer can point at a field without threading a ref. Defaults to null.
- `returnFocus?: boolean` — default true.
- `onEscape?: (event: KeyboardEvent) => void` — the hook never closes
anything; it only tells the innermost trap's owner that Escape was pressed,
because "what Escape means" (close? revert a draft? confirm first?) belongs
to the consumer.
- Export the options and result interfaces plus the initialFocus target type
so consumers can annotate their own wrappers.
- Return a memoized object, and have consumers destructure it (`const { ref }
= …`) rather than write `trap.ref` inside JSX — React's lint rules reject
reading a member named `ref` during render.
- A callback ref, not `useState` + effect, is the point of this shape: focus
is imperative and has to happen the moment the container hits the DOM. The
"keep the node in state, focus in an effect" version has a silent dead
spot — setting state to a value equal to the current one makes React skip
the render, the effect never re-runs, and focus never moves. The hook holds
zero state.
Behavior
- Activation (active && container present): remember `document.activeElement`
*before* moving focus, register the trap (see nesting), attach one
`keydown` listener on `document` in the CAPTURE phase, then move focus in.
Capture, not bubble: overlay panels routinely call `stopPropagation()` on
Escape/Tab in their own handlers, and a bubble-phase listener would never
see those keys.
- Entering focus, in order: the resolved initialFocus element; else, if focus
is already inside the container, leave it alone; else the first tabbable
element; else the container itself.
- Retry path: while the container is not laid out yet (a floating panel with
no coordinates on its first frame, an enter animation starting from
`visibility: hidden`, content still loading), `focus()` fails *silently* —
no throw, no return value, focus simply stays put. So retry the attempt
from a `ResizeObserver` on the container (it fires once right after
`observe()` and again when the box appears) plus a rAF loop capped at ~30
frames for the visibility-only case, which a ResizeObserver never reports.
Give up quietly at the cap: the trap still works and the first Tab pulls
focus in.
- Container focusability: if the container carries no `tabindex` attribute,
add `tabindex="-1"` to it (and remove it on detach if the hook was the one
that added it). Without it, a trap whose content has nothing tabbable has
nowhere to park focus, `container.focus()` fails silently, and focus stays
out on the page behind an `aria-modal` panel.
- Tab handling owns the ENTIRE cycle and never lets the browser move focus
between interior elements. That is not stylistic: the browser happily tabs
to elements this hook deliberately excludes (a button inside an
`aria-hidden="true"` subtree, an `inert` block). Focus lands there, it is
not in the candidate list, the fallback snaps back to the first element,
and focus ping-pongs between those two forever while everything after them
becomes unreachable. So: recompute candidates, find the current index,
`preventDefault()`, focus index ± 1 with wraparound.
- When the focused element is NOT in the candidate list (the container
itself, an element that just got disabled, a background click), resolve the
neighbour by document position with `compareDocumentPosition`: the first
candidate following it going forward, the last one preceding it going
backward, wrapping when there is none. "Always jump to the first element"
breaks the cycle as described above; and from the container itself (which
precedes and contains every candidate) Shift+Tab would otherwise walk
straight out of the trap.
- Candidates are recomputed on every single Tab, never cached: trap content
changes under the user (async form sections, expandable areas, deleted
rows), and a cached list either misses new elements or tries to focus
detached ones — another silent failure.
- Tabbable set: `a[href]`, `button`, `input`, `select`, `textarea`,
`[tabindex]:not([tabindex="-1"])`,
`[contenteditable]:not([contenteditable="false"])`, `audio[controls]`,
`video[controls]`, `details > summary`; minus `[disabled]`; minus anything
under an `inert` or `aria-hidden="true"` ancestor *walking up only as far
as the container* (stop there — hosts often mark their whole app root
`aria-hidden` while a modal is open); minus anything not actually rendered:
`getClientRects().length === 0` (covers `display: none` on the element or
any ancestor), computed `visibility !== "visible"` (an inherited property,
so one check also covers hidden ancestors), or zero area. Order the
survivors the way the browser does: positive `tabindex` ascending first,
then everything else in DOM order.
- Empty trap: with no candidates at all, swallow Tab and park focus on the
container.
- Nesting: keep the registry ON THE DOM — a serial number in a data attribute
on each trapped container plus a counter on `document.body` — not in a
module-level stack. This hook is installed per project, and one page can
end up with two independent copies of it (one shipped inside a dialog
component, another inside a drawer). Module-level stacks then count
separately, both copies believe they are innermost, a single Tab gets
handled twice and focus bounces between layers, and one Escape closes both.
DOM attributes are globally unique, so independent copies cooperate: the
highest serial wins and only that trap reacts to Tab and Escape.
- Deactivation: remove the listener, cancel retries, release the registry
slot, then restore focus — but only if focus is still inside the trap or
has fallen to `<body>`. A user who clicked elsewhere mid-flow must not be
yanked back to the trigger. Guard the restore with `previous.isConnected`
(the trigger is very often unmounted by the same action that closed the
layer — deleting the row it lived in — and focusing a detached node
silently drops focus onto `<body>`), and use
`focus({ preventScroll: true })` so the page does not jump to a trigger
that may be far off-screen.
- SSR-safe: nothing touches `document` during render; only the ref callback
and effects do. Callback props live in refs refreshed each render, so an
inline `onEscape={() => …}` never re-arms anything.
Rendering & styling
- The hook renders no markup and owns no styling. The container stays the
consumer's: `role="dialog"`/`"alertdialog"` and `aria-modal="true"` when
the layer really is modal, `aria-labelledby` pointing at its heading,
`outline-none` plus `focus-visible:ring-2 focus-visible:ring-ring` (the
container itself takes focus in the empty case, so it needs a visible
ring), and semantic tokens only — `bg-popover`/`bg-card`, `border`,
`text-muted-foreground`.
- It is a keyboard trap, not a focus jail: a pointer click can still land
outside, and the next Tab pulls focus back in. Pair it with a scrim,
`inert` on background subtrees, or an outside-click hook when the layer
must block the page, and with a scroll lock for modals.
Customization levers
- `initialFocus` — a ref for a specific field, a selector string
(`"[data-autofocus]"`) to keep the choice in markup, or omit it to take the
first tabbable element. Point it at the least destructive control in a
confirm dialog.
- `returnFocus` — turn it off when the closing action itself moves focus
somewhere better (a wizard advancing to the next step, an editor focusing
the row it just created).
- `onEscape` — close, or revert a draft first, or ignore Escape on a dirty
form and flash the panel instead. Only the innermost trap is called, so
nested layers unwind one press at a time for free.
- The tabbable selector — extend it (custom elements with `tabindex`,
`iframe`) or tighten it (honour a `[data-skip-tab]` opt-out) in one
constant; everything else keys off it.
- The retry cap (~30 frames) — raise it for layers that wait on a network
round trip before rendering anything focusable, or drop the rAF half and
keep only the ResizeObserver if your panels never animate visibility.
- Escalate to a hard trap by adding `inert` to sibling background subtrees on
activation; this hook stays deliberately smaller than that.Concepts
- Silent focus failure —
focus()on adisplay:none,visibility:hiddenor zero-area element does nothing and reports nothing; the trap looks broken (Tab appears dead, Escape never arrives) while the code looks correct. Filtering candidates on real rendered geometry, and retrying entry until the container has a box, is what turns that whole class of bug into a non-event. - Owning the whole cycle — the trap moves focus itself on every Tab instead of only patching the first and last stop. Handing interior moves to the browser leaks, because the browser will focus elements the trap excludes (
inert,aria-hiddensubtrees) and the fallback then snaps back to the start, stranding everything past that point. - Live candidate set — tabbable elements are recomputed on each keypress, so a field added while the layer is open joins the cycle immediately and a removed one leaves it, with no subscription or cache invalidation to get wrong.
- DOM-level nesting registry — "who is innermost" is recorded as a serial number on the container elements themselves rather than in a module variable, because two independently installed copies of the hook on one page have to agree; a module-level stack lets each copy think it is on top, so one Escape closes two layers and Tab bounces between them.
- Escape as a signal, not an action — the hook reports the keypress to the innermost trap and closes nothing, so each layer keeps ownership of its own dismissal semantics (revert a draft, refuse while submitting, ask for confirmation).
- Safe focus restore — the return target is checked with
isConnected, because the trigger is routinely destroyed by the very action that closed the layer, and the restore is skipped entirely if the user has meanwhile clicked somewhere else;preventScrollkeeps the page from jumping back to an off-screen trigger.
usePagination
Headless pagination state — clamped page, page count, item range, a client-side slice() and the ellipsis page sequence, controlled or uncontrolled.
useRovingTabindex
A roving-tabindex hook that collapses a whole control group into one Tab stop — arrow keys inside, with horizontal/vertical/grid lanes, disabled-item skipping, loop-or-stop, Home/End and typeahead.