Popover

Displays rich content in a portal, triggered by a button.

Demo

Dependencies

clsxtailwind-merge

Props

This is a base primitive. It accepts standard HTML attributes plus className for styling via cn().

Source

"use client";

import {
  useState,
  useRef,
  useEffect,
  createContext,
  useContext,
  useCallback,
} from "react";
import { cn } from "@/lib/utils";

interface PopoverContextValue {
  open: boolean;
  setOpen: (open: boolean) => void;
}

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

export interface PopoverProps {
  children?: React.ReactNode;
  className?: string;
}

export function Popover({ children, className }: PopoverProps) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

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

  return (
    <PopoverCtx.Provider value={{ open, setOpen }}>
      <div ref={ref} className={cn("relative inline-flex", className)}>
        {children}
      </div>
    </PopoverCtx.Provider>
  );
}

export function PopoverTrigger({
  children,
  className,
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const { open, setOpen } = useContext(PopoverCtx);
  return (
    <button
      type="button"
      onClick={() => setOpen(!open)}
      className={className}
      {...props}
    >
      {children}
    </button>
  );
}

export function PopoverContent({
  children,
  className,
  align = "center",
  ...props
}: React.HTMLAttributes<HTMLDivElement> & {
  align?: "start" | "center" | "end";
}) {
  const { open } = useContext(PopoverCtx);

  if (!open) return null;

  const alignClass =
    align === "start"
      ? "left-0"
      : align === "end"
        ? "right-0"
        : "left-1/2 -translate-x-1/2";

  return (
    <div
      className={cn(
        "absolute top-full z-50 mt-2 min-w-[12rem] rounded-lg border border-gray-200 bg-white p-4 shadow-sm",
        alignClass,
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
}

CLI

npx genui add popover