Anchored panel: click to open, animates in and out, Escape or outside click closes.
npx @agents-npm-packages/vibeui add popover --dir ./src/components/uiImport
import { Popover } from "@/components/ui/popover"Generated from the component's TypeScript types, so it always matches the source. * marks a required prop.
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger* | ReactNode | — | The element that opens it. |
| children* | ReactNode | — | Content rendered inside the component. |
| className | string | — | 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. |
"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.