Hooks

useClickOutside

A hook that runs a handler when a pointer interaction lands outside one or more referenced elements.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/** Document events that can be listened for. `pointerdown` alone covers mouse, touch and pen. */
export type ClickOutsideEvent = "pointerdown" | "mousedown" | "click" | "touchstart"

/** Any element ref — `useRef<HTMLDivElement>(null)` can be handed straight in. */
export type ClickOutsideRef = React.RefObject<Element | null>

export interface UseClickOutsideOptions {
  /** Unbinds the listener (no point sitting on document while the layer is closed). Defaults to true. */
  enabled?: boolean
  /** Which document events to listen for. Defaults to ["pointerdown"]. */

Installation

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

Prompt

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 whose isConnected is false is what keeps self-unmounting menu items from collapsing their own parent layer.
  • pointerdown over click — 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 enabled plus 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).

On This Page