useClickOutside
A hook that runs a handler when a pointer interaction lands outside one or more referenced elements.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-click-outside.jsonPrompt
Build a React + TypeScript "useClickOutside" hook (no dependencies beyond
React; uses DOM events only).
Contract
- `useClickOutside(refs, handler, options?): void` where
`refs: RefObject<Element | null> | RefObject<Element | null>[]`,
`handler: (event: Event) => void`, and
`options?: { enabled?: boolean; events?: ("pointerdown" | "mousedown" |
"click" | "touchstart")[] }`.
- `enabled` defaults to `true`, `events` defaults to `["pointerdown"]`.
- Export the options interface and the ref/event type aliases so consumers
can annotate their own wrappers without redeclaring the shapes.
- Accepting an array is the point of the contract, not a convenience: a
dropdown's trigger and its floating panel are two sibling nodes (often the
panel is portalled elsewhere entirely), and both must count as "inside" —
otherwise pressing the trigger while open closes and reopens the layer in
the same gesture and it appears never to open at all.
Behavior
- A single `document` listener per configured event name, attached in an
effect (never during render) and removed on unmount, on `enabled` going
false, and before every re-attach.
- On each event: bail out unless `event.target instanceof Node`; then bail
out if `!target.isConnected` — the node may already have been removed from
the document by the time the event is handled (a menu item that unmounts
itself, a row deleted by the very click being processed). A detached target
fails `contains()` against every element, so without this guard it reads as
an outside click and tears down the parent layer too. Then walk the refs
and return early if any `ref.current.contains(target)`. Only if all checks
pass, call the handler with the raw DOM event.
- `handler` and `refs` are kept in refs that are refreshed in a bare effect
on every render, and `events` is collapsed into a joined string key used as
the effect dependency. Consumers pass inline arrow functions and inline
array literals as a matter of course; putting those in the dependency array
directly would unbind and rebind the document listener on every render.
- Default to `pointerdown` rather than `click`: dismissal should happen on
press for a responsive feel, and it avoids the drag case where the press
starts inside the layer and the release happens outside.
- SSR-safe by construction: `document` is only touched inside effects, so the
hook renders identically on the server and during hydration.
Rendering & styling
- The hook renders nothing and owns no markup — the consumer keeps ownership
of the layer, its `aria-expanded`/`role="menu"` semantics, focus return to
the trigger after dismissal, and any enter/exit transition (which should
carry `motion-reduce:transition-none`). Any surface built on it should use
semantic tokens (`bg-popover`, `border`, `bg-accent`/`text-accent-foreground`
for hovered items, `focus-visible:ring-2 focus-visible:ring-ring`).
- SSR note: the first server render and the first client render both run with
no listener attached; nothing about the returned UI depends on the browser,
so there is no hydration flash to design around.
- Pair it with an Escape-key binding — outside-click alone is not keyboard
accessible, and a dismissable layer needs both.
Customization levers
- `events` — add `"touchstart"` for older mobile browsers where `pointerdown`
coverage is a concern, or switch to `["click"]` when the layer sits above
content whose own press handlers must win first.
- `enabled` — gate on the layer's open state so no document listener exists
while it is closed; also the escape hatch for "modal is open, don't dismiss
the layer underneath".
- Number of refs — one ref for self-contained panels, an array for
trigger + portalled panel, and more for compound layers (a toolbar, its
popover, and a nested colour picker all counting as inside).
- Handler payload — the raw DOM event is passed through, so a consumer can
inspect `event.target` for finer-grained rules (ignore clicks on a specific
ignore-list selector, for example) without changing this hook.
- A `useDismissable({ onDismiss })` wrapper that also binds Escape and
restores focus to the trigger is the natural next layer up; keep it as a
wrapper rather than folding key handling into this hook's contract.Concepts
- Outside dismissal as a boundary test — "outside" is not a coordinate check but a containment test against a set of nodes; the layer's DOM subtree is the boundary, which is why portalled content must be handed in as its own ref rather than assumed to be nested.
- Multi-ref trigger exclusion — including the trigger in the ref set is what makes the toggle button behave: without it, pressing an open dropdown's trigger dismisses first and re-opens second, and the layer looks permanently stuck closed.
- Detached-target guard — an element removed from the DOM during the same gesture is contained by nothing, so
contains()reports "outside" for a click that was visually inside; skipping targets whoseisConnectedis false is what keeps self-unmounting menu items from collapsing their own parent layer. pointerdownoverclick— one event name covers mouse, touch and pen, and firing on press means the layer closes the instant the user commits to the gesture instead of waiting for release; the trade-off is that a press-inside/release-outside drag no longer dismisses, which is usually the desired behaviour.- Listener lifecycle vs. render churn — the handler and ref list live in refs refreshed each render while the effect keys only on
enabledplus a serialized event list, so the document listener is bound once per open layer no matter how often the consumer re-renders. - Not a focus manager — the hook answers "did the pointer land elsewhere", nothing more; keyboard dismissal, focus return, and inert backgrounds remain the consumer's job (or a reason to reach for a full Radix primitive instead).