Skip to content

Popover

MD · 1950B gzip budget

A disclosure panel that closes on outside click and Escape.

Category
Overlays
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/popover.json

Copies the source into your project. Pulls in 1 shared registry item: cn.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useEffect, useId, useRef, useState } from "react";
import { cn } from "../lib/cn";

export interface PopoverProps {
  trigger: (props: { onClick: () => void; "aria-expanded": boolean; "aria-controls": string }) => React.ReactNode;
  children: React.ReactNode;
  className?: string;
}

/**
 * A disclosure panel anchored to its trigger. Closes on outside click and on
 * Escape (returning focus to the trigger), and exposes `aria-expanded` /
 * `aria-controls` through the trigger render prop so the calling button's own
 * markup stays in the caller's control.
 */
export function Popover({ trigger, children, className }: PopoverProps) {
  const [open, setOpen] = useState(false);
  const id = useId();
  const rootRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;

    function onPointerDown(event: PointerEvent) {
      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
    }
    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        setOpen(false);
        triggerRef.current?.querySelector("button")?.focus();
      }
    }

    document.addEventListener("pointerdown", onPointerDown);
    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("pointerdown", onPointerDown);
      document.removeEventListener("keydown", onKeyDown);
    };
  }, [open]);

  return (
    <div ref={rootRef} className="relative inline-block">
      <div ref={triggerRef}>
        {trigger({ onClick: () => setOpen((v) => !v), "aria-expanded": open, "aria-controls": id })}
      </div>
      {open && (
        <div
          id={id}
          role="dialog"
          className={cn(
            "absolute start-0 top-full z-10 mt-2 min-w-48 rounded-lg border border-neutral-200 bg-white p-3 text-start shadow-lg dark:border-neutral-800 dark:bg-neutral-900",
            className,
          )}
        >
          {children}
        </div>
      )}
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation

Used by