shadcn.io is an independent project built on shadcn/ui
useOnClickOutside runs your handler when an event happens outside the node on a ref. Pass a list of refs when a trigger and a menu should both count as inside.
The default event is mousedown. Switch it for a touch or focus event when that fits the control. Pair the handler with useBoolean when an outside event should clear a flag. In React the listener attaches after mount and calls the latest handler.
Installation
Anatomy

Usage
import { useRef } from "react";
import { useOnClickOutside } from "@/hooks/use-on-click-outside";const panelRef = useRef<HTMLDivElement>(null);
useOnClickOutside(panelRef, () => {
close();
});The third argument is the event name and defaults to mousedown. The fourth is forwarded to addEventListener. Pass an array of refs to treat several elements as inside.
FAQ
Can I ignore clicks on more than one element?
Yes. Pass an array of refs instead of one ref. The handler runs only when the event target sits outside every ref that currently points at a node. A ref whose current value is null is skipped. Use that for a button and a menu rendered in a portal: a click on either node does not count as outside. One ref is enough for a single panel.
Which event should I listen for?
mousedown is the default, and it runs before click finishes, which suits most menus. mouseup waits until the button comes up. touchstart and touchend are for a finger. focusin and focusout tell you that keyboard focus entered or left, which helps an accessible popup. Pass the name as the third argument. Changing it replaces the listener.
Does a portal count as inside?
Only if you also pass a ref to the portal's node. The check uses contains on the elements you referenced. A portaled menu is not a DOM child of the button, so a click in the menu looks outside when the menu ref is missing. Pass both refs in an array. The hook does not walk the React tree. It only looks at those nodes.
Will a new handler re-subscribe the listener?
No. The newest handler is saved during layout, and the document listener calls that saved function. A new handler does not remove the listener. The listener does get replaced when the ref, the event name, or the listener options change. Pass a stable options object if you use the fourth argument, because a new object is a new dependency.
Why might an outside event do nothing?
The handler is skipped when the event target is not connected to the document, which avoids acting on a node that was already removed. A single ref with a null current also skips the call. An array fires only when the target is outside each attached ref. The hook does not close anything itself. If a child stops the event, the document listener never sees it.
Is the listener attached during server rendering?
No. The listener is added in an effect, and the handler is copied in a layout effect. The server render returns nothing and does not touch document. Cleanup removes the listener on unmount or when its dependencies change. The layout copy means the first client event already uses the handler from that render. It is safe on a server-rendered page.
API Reference
Parameters
refReact.RefObject<HTMLElement | null> | React.RefObject<HTMLElement | null>[]
A ref to the element, or an array of refs: inside any of them doesn't count.
- Type
React.RefObject<HTMLElement | null> | React.RefObject<HTMLElement | null>[]
handler(event: MouseEvent | TouchEvent | FocusEvent) => void
Called with the event that landed outside.
- Type
(event: MouseEvent | TouchEvent | FocusEvent) => void
eventType?"mousedown" | "mouseup" | "touchstart" | "touchend" | "focusin" | "focusout"
The document event that counts as a press.
- Type
"mousedown" | "mouseup" | "touchstart" | "touchend" | "focusin" | "focusout"- Default
"mousedown"
eventListenerOptions?AddEventListenerOptions
Options for the document listener (capture, passiveโฆ).












