useFocusWithin
A callback-ref hook that reports whether focus is inside a subtree — no flicker when focus moves between children, and it still flips off when the focused node is removed.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-focus-within.jsonPrompt
Build a React + TypeScript "useFocusWithin" hook (React only — no npm
dependencies; browser focus events plus MutationObserver).
Contract
- `useFocusWithin<T extends HTMLElement = HTMLElement>(options?: {
onFocusWithin?: (event: FocusEvent | null) => void
onBlurWithin?: (event: FocusEvent | null) => void
}): { ref: (node: T | null) => void; focused: boolean }`
- `ref` is a **callback ref**, not a RefObject: consumers attach it to the
root of the subtree they care about — `<div ref={ref}>`. React calls it on
every mount, replace and unmount, so re-binding to the current node is
automatic instead of something an effect has to chase.
- `focused` means exactly one thing: `document.activeElement` is that node or
one of its DOM descendants. It is the JS reading of the CSS `:focus-within`
pseudo-class, nothing more.
- `onFocusWithin(event)` fires when focus enters from outside; `event` is the
`focusin` that carried it in, or `null` when focus was already inside at the
moment the node was attached (an `autoFocus`-ed field, a conditional branch
that mounted around the already-focused node) — that `focusin` fired before
any listener existed and is recovered by reconciliation, not by an event.
- `onBlurWithin(event)` fires when focus leaves; `event` is the `focusout`, or
`null` when the departure was found by the removal watcher (see Behavior).
- Detaching the node (unmount, conditional swap) resets `focused` to false
**silently** — no `onBlurWithin`. The subtree being described is gone, so
"focus left it" is not something a consumer can act on; teardown belongs in
the consumer's own effect cleanup.
- The hook never moves focus, never writes `tabindex`, never adds an ARIA
attribute, and never renders anything. It observes.
Behavior
- Bind `focusin` and `focusout` on the node itself. Not `focus`/`blur`: those
do not bubble, so a listener on the container never hears a child change
focus. `focusin`/`focusout` bubble, so one pair of listeners covers the whole
subtree — including descendants mounted later, with no per-control wiring.
- `focusin` → enter: cancel any pending verification (see below), start the
removal watcher, commit `true`.
- `focusout` → resolve `event.relatedTarget`, which is the element that is
about to receive focus, in three cases:
1. relatedTarget is a Node inside the subtree → **swallow the event**. This
is focus changing hands between two children (Tab to the next field,
click another button in the same group), and the browser fires a real
`focusout` for it. Reacting to it is what makes naive implementations
blink false-then-true for one frame — enough to flash a highlight and to
make a "save on blur" fire on every hop.
2. relatedTarget is a non-null element outside the subtree → the information
is certain: commit `false` immediately, in the same tick. "Tab out of the
group and it commits" should not wait a frame.
3. relatedTarget is `null` → **ambiguous**. It covers focus genuinely landing
on nothing (a click on dead space), Safari reporting no relatedTarget for
some clicks, and the whole window losing focus. Do not conclude anything:
schedule one `requestAnimationFrame` and then read the single source of
truth, `document.activeElement`. Still inside → do nothing. Outside →
commit `false`, passing the original event. Any `focusin` arriving before
that frame cancels it.
It must be rAF, not a microtask: the focus sequence is blur → focusout →
focus → focusin, and a microtask checkpoint runs after each handler
returns, so a microtask would read `document.activeElement` before the new
element has taken focus. rAF also does not run in a background tab, which
is what makes `focused` survive a tab switch exactly like `:focus-within`
does — focus has not moved, so nothing should change.
- Removed focused node: while (and only while) focus is inside, run a
`MutationObserver` on the node with `{ childList: true, subtree: true }`. On
any structural change, if the node no longer contains
`document.activeElement`, commit `false` with a `null` event, then disconnect.
Necessary because deleting the currently focused element drops focus onto
`<body>` and browsers do not agree on whether they fire `focusout` for that —
an event-only implementation gets stuck at `focused: true` forever, so the
save never runs and the highlight never clears. Only `childList` is observed;
attributes and text are irrelevant and would fire constantly. The observer's
callback lands as a microtask after the removing task, i.e. after React's
commit — so the common "delete the focused row, then focus the next one"
pattern, when the re-focus happens in the same commit (a layout effect or the
handler itself), is seen as focus still inside and never flips. A re-focus
deferred to a later task flips false first and is corrected by the incoming
`focusin`; if that matters, do the re-focus synchronously.
- Keep a **synchronous mirror ref** of the boolean and read/write it inside the
handlers. `focusout` and `focusin` for one move arrive in the same tick,
before any re-render, so a state-only guard reads a stale value and either
double-fires the callbacks or misses the transition entirely. State is for
rendering; the ref is the guard.
- Keep the option callbacks in a latest-ref refreshed each render. Putting them
in a dependency array would re-bind both listeners on every render, since
`onBlurWithin={() => save()}` inline arrows are the normal way to call this.
- Attaching a node: remove listeners from the previous node, cancel the pending
frame, disconnect the observer, reset state silently, bind to the new node,
then reconcile — if the new node already contains `document.activeElement`,
enter with a `null` event. Without that step a subtree that mounts around the
focused element reports a false `false` that never corrects itself.
- Degenerate cases. A subtree with nothing focusable simply stays `false`
forever — that is the correct answer, not a bug to paper over. Focus moving
into portalled content counts as leaving: containment is DOM containment, not
React-tree containment. Nested groups both report `true`, because `focusin`
bubbles through both. Every instance owns its listeners and state; there is
no shared store. Nothing touches `document` during render, so it is SSR-safe.
- Cleanup, on detach and on unmount: remove both listeners, cancel the pending
animation frame, disconnect the observer. Nothing may outlive the component —
and keep a component-level effect cleanup as the backstop for the case where
the ref was never attached.
Rendering & styling
- The hook renders nothing. Consumers branch on `focused` with `cn()` and
semantic tokens only — `border-primary` / `bg-primary/5` for the active
group, `border-dashed` / `bg-muted` / `text-muted-foreground` for the idle
one — never hard-coded colors.
- If the only thing that changes is appearance, do not use this hook: the CSS
`:focus-within` pseudo-class already does it, for free, with no JS state.
- Any reveal driven by `focused` must respect `prefers-reduced-motion`
(`motion-reduce:transition-none`) and must keep working with motion off —
and must never unmount or hide the control that currently holds focus, which
would bounce focus to `<body>` mid-interaction.
- Keyboard map: the hook intercepts no keys at all. Tab / Shift+Tab (and
clicks) move focus, the browser fires the events, the hook only reports.
Escape, Enter and shortcuts stay entirely with the consumer.
- ARIA contract: the hook contributes nothing, so the group keeps its own
semantics — `fieldset`/`legend` for form groups, `role="group"` or
`role="toolbar"` with an `aria-label` for action clusters. Never put the
native `disabled` attribute on a control the user may currently be focused
on: the browser blurs it to `<body>`, which reads as "focus left the group".
Use `aria-disabled` plus a guard in the handler so the control stays
focusable, announced, and able to explain its refusal.
Customization levers
- Extra roots: to count a portalled popover (a Radix menu opened from inside
the group) as "still within", accept an array of refs or a
`contains?: (target: Element) => boolean` predicate and OR it into the two
containment checks (the `relatedTarget` test and the deferred
`activeElement` test) — those are the only two places containment is decided.
- Leave grace period: swap the single rAF for a short `setTimeout` (120–200ms)
on the leave path when focus legitimately makes a round trip outside the
subtree (a native color/file picker, a toolbar that re-mounts its buttons);
cancel it on the next `focusin`, exactly as the frame is cancelled today.
- One callback instead of two: `onChange?: (focused: boolean) => void` if the
consumer only mirrors the boolean into other state.
- Keyboard-only flavour: track whether the last input modality was a key press
(`keydown` on document sets a flag, `pointerdown` clears it) and expose a
second boolean to mirror `:focus-visible`, so a toolbar can reveal itself
for keyboard users without flashing on every mouse click.
- Very large subtrees: narrow the observer to the one container whose children
actually come and go, rather than the whole group, if the group holds
thousands of nodes and mutates constantly.Concepts
- relatedTarget as the anti-flicker guard — a
focusoutfires even when focus merely changes hands between two children of the same group.focusout.relatedTargetnames the element about to receive focus, so "is it still inside?" is answerable synchronously, with no timers: the event is swallowed and the boolean never blinks. This single check is the difference between a group highlight that sits still while you Tab through five fields and one that strobes. - The ambiguous
null— a nullrelatedTargetmeans "the browser will not say", and it covers three unrelated situations: focus landing on nothing, a Safari click quirk, and the whole window going away. The hook refuses to guess, defers exactly one animation frame and asksdocument.activeElementinstead — late enough that the incomingfocusinhas already landed, and conveniently frozen while the tab is in the background, which is why a tab switch does not fake a departure. - Removal recovery — deleting the focused element is the one departure that may fire no focus event at all; the browser just moves focus to
<body>. AMutationObserverthat lives only while focus is inside re-checks containment on any structural change, so "the row I was on deleted itself" still ends the focus-within session. Reacting to structure rather than to events is what makes it browser-independent. - Observe, never move — this hook reports where focus is and stops there: no
focus()call, no injectedtabindex, no key interception. That boundary is what separates it from a focus trap, and it is why two of them can watch nested groups without fighting each other. - Commit-on-group-blur — the interaction pattern this exists for: a draft is committed once, when focus leaves the whole cluster, instead of on every field's own blur. Per-field
onBlursaves half-typed values and fires again on the way back; group-level blur matches what a user thinks of as "I'm done with this block". - DOM containment, not React containment — "inside" is decided by
node.contains(), so a portalled menu that is a React child but a DOM sibling of<body>counts as outside. That is a deliberate, checkable rule rather than a surprise: the two places containment is tested are the only hooks a consumer needs to extend for portal-aware behaviour.