Weekly release · #5

click-outside-hook: close popovers without treating their portals as outside

TL;DR: Share outside-interaction behavior across React dropdowns and popovers, with explicit portal exclusions and tests for document boundaries.

View the repository on GitHub

  • react
  • hooks
  • popovers
  • testing
  • open-source

Release #5 in our open-source series. A dropdown should close when someone clicks elsewhere, but stay open when they use its own controls. click-outside-hook gives React teams one place to define that boundary, including menus whose visible pieces live in different parts of the page.

Why it matters

A simple “close when the click is outside this box” rule works until the interface stops being one box. A date picker might open from a filter panel but render elsewhere in the page. A nested menu can look like part of its parent while sitting outside the parent's document structure. Clicking those controls can accidentally dismiss the interface the person is still using.

A React portal renders content into a different place in the page's document structure. Visual containment and document containment are therefore different questions. Teams need to decide which elements count as inside, instead of assuming that everything drawn near a dropdown is its child.

Repeated implementations also differ in when they close. Closing on mouse-down and closing after a completed click can behave differently during dragging or when another event handler stops the event from travelling through the page.

What it does

  • Closes things at the right moment. It calls a function the application supplies when a mouse or touch interaction starts outside every tracked element. The application decides whether that means closing a menu, hiding a popover, or something else.
  • Lets one menu span several places. A ref is a React reference to a rendered element. The hook accepts one ref, several refs, elements directly, or callback refs wrapped with its trackCallbackRef helper. The ignore option lets a portal's content count as inside even when it lives elsewhere in the document.
  • Cleans up after itself. By default it listens for mousedown and touchstart. It can be switched off while the interface is closed, and it removes its listeners when the component unmounts.
  • What it does not do: render a menu, or supply keyboard navigation, focus management, or dialog accessibility. Those still belong in the component layer.

Who it's for

  • Frontend engineers building dropdowns and popovers that should share dismissal behavior.
  • Design-system maintainers who want one definition of "outside" instead of slightly different listeners copied into each component.
  • Teams with same-origin embedded interfaces who need to attach the hook to a specific document while keeping that document boundary explicit.

What it checks

The hook tests each event target against the configured elements. The release's automated tests verify these behaviors:

Case Expected behavior
Mouse-down inside a tracked element Do not call the outside handler
Mouse-down outside all tracked elements Call the handler
Multiple tracked refs Treat each as inside
Explicitly ignored portal content Keep the parent interaction open
Disabled hook Attach no outside-interaction listeners
Changed handler Use the latest handler without reattaching stable listeners
Unmount Remove the listeners
Tracked callback ref Resolve its current element
Server rendering without document Render without a document-access error
Configured iframe document Recognize that document's targets and ignore parent-document events
Touch interaction Distinguish inside and outside targets
Stopped bubble propagation Observe the event during capture

These are fixture-level interaction tests, not an audit of the accessibility or usability of a consuming application.

Architecture and tech stack

useClickOutside attaches document listeners and compares the event target with each resolved element using equality and containment. normalizeRefs accepts the supported input forms; trackCallbackRef records callback-ref nodes in a weak map. A ref to the latest handler lets a stable listener call the current application function.

Loading diagram...
Technology Role Why it was chosen
React Hook lifecycle and refs; supplied as a peer dependency A peer dependency, so the hook uses the application's own React and adds no runtime dependencies
TypeScript Hook types and checks of source, tests, and examples Type-checking the examples caught the popover's mixed button and div refs before release
tsup Minified ESM and CommonJS bundles plus declarations One build step for both module systems
Vitest and Testing Library Interaction and regression tests Tests dispatch mouse and touch events at rendered components, the way an application uses the hook
jsdom Simulated document and iframe environments Lets tests create a second document for the iframe cases without a browser

The hook has no non-peer runtime dependencies. The test and build tools are development dependencies.

Key design decisions and trade-offs

Make the boundary explicit. The hook does not discover portals automatically. Applications pass portal elements in refs or ignore. That keeps the behavior inspectable, but requires the caller to include every piece that should count as inside.

Observe the start of the interaction. Mouse-down and touch-start detect the initial target. A drag that starts inside and ends outside does not create a later close signal from a mouse-up listener. Applications can override the event list when they need different semantics; those alternatives need their own interaction checks.

Listen during capture. Capture happens before ordinary bubbling handlers, so a later handler stopping propagation does not hide the event. The outside handler may therefore run before other handlers on the target; consumers should consider that ordering when changing UI state.

Choose one document. ownerDocument selects where listeners attach. It does not make iframe events bubble to the parent or subscribe to two documents at once.

Under the hood

An iframe has its own JavaScript objects, including its own Node constructor. Checking its event target against the parent window's Node can reject a valid target before containment is evaluated.

The listener now uses the selected document's constructor:

const target = event.target;
const NodeConstructor = ownerDocument.defaultView?.Node;
if (!NodeConstructor || !(target instanceof NodeConstructor)) {
  return;
}

The default document lookup is also guarded with typeof document !== "undefined", so rendering on a server does not require browser globals. Listeners remain an effect that runs in the browser.

Sharp edges and benchmarks

Release review found two defects: server rendering could access a missing document, and iframe targets could fail the parent-window Node check. The new regression suite produced two failures against the original implementation. Both pass with the fixes, and the complete suite now reports:

Test Files  1 passed (1)
Tests  12 passed (12)

A separate TypeScript check found that the popover example needed useClickOutside<HTMLElement> when combining a button ref and a div ref. The release's check command now checks examples as well as building and testing the hook.

The minified ESM bundle is 847 bytes. Python gzip with mtime=0 produces 513 bytes, exceeding the old 500-byte target. These are artifact-size measurements on the release output, not runtime benchmarks; no interaction-performance benchmark was run.

Compatible dependency updates cleared the high and moderate audit findings. One low-severity esbuild development dependency finding remains; the production dependency audit reports zero findings. Commands and evidence are recorded in validation.md.

Quick start

Use Node.js 22.12 or later for the source toolchain; this release was validated on Node.js 24.13.0 with React 19. Clone and test the source, then create an installable archive:

git clone https://github.com/jare20895/click-outside-hook
cd click-outside-hook
npm ci
npm run check
npm pack

Install the archive in your React application:

npm install /absolute/path/to/click-outside-hook/click-outside-hook-0.1.0.tgz

This is a GitHub source release; the commands do not assume an npm registry publication. A simple popover tracks both its button and panel so clicking either counts as inside:

import { useRef, useState } from "react";
import { useClickOutside } from "click-outside-hook";

export function Details() {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);

  useClickOutside<HTMLElement>([triggerRef, panelRef], () => setOpen(false), {
    enabled: open,
  });

  return (
    <>
      <button ref={triggerRef} aria-expanded={open} onClick={() => setOpen(!open)}>
        Details
      </button>
      {open && <div ref={panelRef}>Additional information</div>}
    </>
  );
}

For a portalled child, pass its ref through ignore or include it in the tracked refs. The repository includes dropdown, popover, and nested-menu examples. Applications using a framework with server and client component boundaries must place hook usage in a client component.

Path to production

Start with a real-browser test of your own trigger, panel, and portal elements. Check opening and closing, interaction ordering, touch input, and cleanup when navigating away. Add the keyboard and focus behavior appropriate to the interface; outside detection is only one part of it.

The backlog calls for real-browser and device coverage, evaluation of Shadow DOM composed paths, and a separate React 18 compatibility run. The release tests use React 19, despite the wider declared peer range. Resolve the remaining development dependency finding when a compatible update is available, and reassess the historical size target against the behavior the application needs.

Limits, plainly

Portal exclusion is explicit, not automatic. Each hook listens to one document. Same-origin iframe support does not bypass cross-origin restrictions, and iframe events do not bubble into the parent document.

The tests use jsdom; physical touch devices, real-browser iframe behavior, and Shadow DOM composed paths have not been validated. There is no built-in Escape handling, focus restoration, focus trap, accessible naming, or full menu navigation.

The source is MIT licensed. See the public repository for implementation, examples, and the remaining issues.