Popover

Floating content panel triggered by a button — supports click-outside and keyboard dismiss for info cards, forms, and contextual menus.

Preview

Basic popover

import { Popover, PopoverButton, PopoverPanel } from '@/registry/ui-kit/tailwind-popover/react'

function Example() {
  return (
    <Popover>
      <PopoverButton className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white">
        View details
      </PopoverButton>
      <PopoverPanel>
        <p className="text-sm text-zinc-600">Popover content goes here.</p>
      </PopoverPanel>
    </Popover>
  )
}

Component API

PropDefaultDescription
PopoverWrapper that manages open/close state, click-outside, and Escape key.
PopoverButtonTrigger that toggles the popover panel.
PopoverPanelPositioned content container rendered below the trigger.

Source Code

"use client";

import React, { useState, useRef, useEffect, createContext, useContext } from "react";

interface PopoverContextValue {
  open: boolean;
  setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}

const PopoverContext = createContext<PopoverContextValue>({
  open: false,
  setOpen: () => {},
});

/* ── Popover ────────────────────────────────────────── */
export function Popover({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  // Click outside
  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, []);

  // Escape key
  useEffect(() => {
    function handleKey(e: KeyboardEvent) {
      if (e.key === "Escape") setOpen(false);
    }
    if (open) {
      document.addEventListener("keydown", handleKey);
      return () => document.removeEventListener("keydown", handleKey);
    }
  }, [open]);

  return (
    <PopoverContext.Provider value={{ open, setOpen }}>
      <div ref={ref} className={`relative ${className}`}>
        {children}
      </div>
    </PopoverContext.Provider>
  );
}

/* ── PopoverButton ──────────────────────────────────── */
export function PopoverButton({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  const { open, setOpen } = useContext(PopoverContext);
  return (
    <button
      type="button"
      onClick={() => setOpen(!open)}
      aria-expanded={open}
      className={className}
    >
      {children}
    </button>
  );
}

/* ── PopoverPanel ───────────────────────────────────── */
export function PopoverPanel({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  const { open } = useContext(PopoverContext);
  if (!open) return null;
  return (
    <div
      className={`absolute z-10 mt-2 w-72 rounded-lg border border-zinc-200 bg-white p-4 shadow-lg dark:border-zinc-700 dark:bg-zinc-900 ${className}`}
    >
      {children}
    </div>
  );
}