VibeUI
ComponentsDocs
VibeUI

Components with taste, that you own. Copy the source, retint the tokens, keep every line.

Library

  • All components
  • Signature pieces
  • Text effects
  • Footers

Docs

  • Introduction
  • Installation
  • Theming
  • CLI

Project

  • GitHub
  • Agents and MCP
  • Issues
  • MIT license
© 2026 VibeUI · MIT licensed
  1. Components
  2. Overlays
  3. Popover

Popover

Anchored panel: click to open, animates in and out, Escape or outside click closes.

OverlaysClient component

Install

bash
npx @agents-npm-packages/vibeui add popover --dir ./src/components/ui

Import

tsx
import { Popover } from "@/components/ui/popover"

API

Generated from the component's TypeScript types, so it always matches the source. * marks a required prop.

<Popover>

PropTypeDefaultDescription
trigger*ReactNode—The element that opens it.
children*ReactNode—Content rendered inside the component.
classNamestring—Extra classes, merged with the component's own.
align"start" | "end" | "center""start"Alignment relative to the trigger.
side"top" | "bottom""bottom"Which edge or side to anchor to.

Source

src/components/ui/popover.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/** Anchored panel: click to open, animates in and out, Escape or outside click closes. */
export function Popover({
  trigger,
  children,
  className,
  align = "start",
  side = "bottom",
}: {
  trigger: React.ReactNode;
  children: React.ReactNode;
  className?: string;
  align?: "start" | "center" | "end";
  side?: "top" | "bottom";
}) {
  const [open, setOpen] = React.useState(false);
  // Stay mounted until the exit animation has played.
  const [present, setPresent] = React.useState(false);
  if (open && !present) setPresent(true);
  const ref = React.useRef<HTMLDivElement>(null);
  const id = React.useId();

  React.useEffect(() => {
    if (!open) return;
    const onDoc = (e: MouseEvent) => {
      if (!ref.current?.contains(e.target as Node)) setOpen(false);
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") setOpen(false);
    };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDoc);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  return (
    <div ref={ref} className="relative inline-flex">
      <span
        className="inline-flex"
        aria-expanded={open}
        aria-controls={present ? id : undefined}
        onClick={() => setOpen((v) => !v)}
      >
        {trigger}
      </span>
      {present ? (
        <div
          id={id}
          data-state={open ? "open" : "closed"}
          onAnimationEnd={(e) => {
            if (e.target === e.currentTarget && !open) setPresent(false);
          }}
          className={cn(
            "absolute z-30 min-w-56 rounded-md border border-line bg-surface p-3.5 shadow-lift data-[state=closed]:animate-pop-out data-[state=open]:animate-pop-in",
            !open && "pointer-events-none",
            side === "bottom" ? "top-[calc(100%+8px)]" : "bottom-[calc(100%+8px)]",
            align === "start" && "left-0",
            align === "center" && "left-1/2 -translate-x-1/2",
            align === "end" && "right-0",
            className,
          )}
        >
          {children}
        </div>
      ) : null}
    </div>
  );
}

Exports Popover. Peer dependencies: clsx, tailwind-merge.

PreviousLightboxNextSheet