Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Selector for focusable candidates. Only elements Tab can stop on:
 * `[tabindex="-1"]` is programmatically focusable but out of the tab order and
 * must be excluded, or the cycle parks on somewhere the user can never reach by
 * pressing Tab. `details > summary` is the disclosure triangle itself (natively
 * focusable), and the control bar of `audio/video[controls]` takes Tab too. The
 * real visibility / inert / aria-hidden filtering happens below; this selector
 * is only a coarse pass.
 */
const FOCUSABLE_SELECTOR = [

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-focus-trap.json

Prompt

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 failurefocus() on a display:none, visibility:hidden or 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-hidden subtrees) 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; preventScroll keeps the page from jumping back to an off-screen trigger.

On This Page