Popover
An anchored panel for filters, forms and details.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/popover.jsoncomponents/examples/popover-demo.tsx
"use client";
import * as React from "react";
import { Filter } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover } from "@/components/ui/popover";
const statuses = ["Open", "In progress", "Blocked", "Done"];
export default function PopoverDemo() {
const [picked, setPicked] = React.useState<string[]>(["Open", "In progress"]);
return (
<Popover trigger={<Button variant="outline"><Filter /> Status · {picked.length}</Button>}>
{(close) => (
<div className="w-52 space-y-3">
<p className="text-sm font-medium">Filter by status</p>
{statuses.map((s) => (
<label key={s} className="flex items-center gap-2.5 text-sm">
<Checkbox checked={picked.includes(s)} onCheckedChange={(c) => setPicked((p) => (c ? [...p, s] : p.filter((x) => x !== s)))} aria-label={s} />{s}
</label>
))}
<div className="flex justify-between border-t border-border pt-3">
<Button size="sm" variant="ghost" onClick={() => setPicked([])}>Clear</Button>
<Button size="sm" onClick={close}>Apply</Button>
</div>
</div>
)}
</Popover>
);
}When to use
- Small interactive panels anchored to a button: filters, share, quick edit.
When not to use
- Lists of actions: use a dropdown menu.
- Plain hints: use a tooltip.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/popover.json1Install the dependencies
npm install motion2Copy the source into your project
components/ui/popover.tsx"use client"; import * as React from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface PopoverProps { trigger: React.ReactElement<React.ButtonHTMLAttributes<HTMLButtonElement> & { ref?: React.Ref<HTMLButtonElement> }>; children: React.ReactNode | ((close: () => void) => React.ReactNode); align?: "start" | "center" | "end"; side?: "bottom" | "top"; open?: boolean; onOpenChange?: (open: boolean) => void; className?: string; } /** A floating panel anchored to a trigger. Closes on outside click and Escape. */ export function Popover({ trigger, children, align = "start", side = "bottom", open, onOpenChange, className }: PopoverProps) { const id = React.useId(); const [internal, setInternal] = React.useState(false); const isOpen = open ?? internal; const rootRef = React.useRef<HTMLDivElement>(null); const setOpen = React.useCallback( (v: boolean) => { if (open === undefined) setInternal(v); onOpenChange?.(v); }, [open, onOpenChange], ); const close = React.useCallback(() => { setOpen(false); document.getElementById(`${id}-trigger`)?.focus(); }, [setOpen, id]); React.useEffect(() => { if (!isOpen) return; const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && setOpen(false); const onKey = (e: KeyboardEvent) => e.key === "Escape" && close(); document.addEventListener("pointerdown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); }; }, [isOpen, setOpen, close]); return ( <div ref={rootRef} className="relative inline-flex"> {React.cloneElement(trigger, { id: `${id}-trigger`, "aria-expanded": isOpen, "aria-controls": isOpen ? id : undefined, "aria-haspopup": "dialog", onClick: () => setOpen(!isOpen), })} <AnimatePresence> {isOpen && ( <motion.div id={id} role="dialog" initial={{ opacity: 0, scale: 0.96, y: side === "bottom" ? -4 : 4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.1 } }} transition={{ type: "spring", bounce: 0.15, duration: 0.28 }} style={{ transformOrigin: `${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : align === "center" ? "center" : "left"}` }} className={cn( "absolute z-50 min-w-56 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-elev-3", side === "bottom" ? "top-full mt-2" : "bottom-full mb-2", align === "start" && "left-0", align === "end" && "right-0", align === "center" && "left-1/2 -translate-x-1/2", className, )} > {typeof children === "function" ? children(close) : children} </motion.div> )} </AnimatePresence> </div> ); }
Usage
Import it from @/components/ui/popover after installing.
example.tsx
import { Popover } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
export function Share() {
return (
<Popover trigger={<Button variant="outline">Share</Button>} align="end">
{(close) => (
<div className="space-y-2 p-3">
<p className="text-sm">Anyone with the link can view.</p>
<Button size="sm" onClick={close}>Done</Button>
</div>
)}
</Popover>
);
}API reference
Popover
A dialog panel anchored to its trigger. Children can be a function that receives close().
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | ReactElement | – | A button; ARIA and handlers are added. |
| children | ReactNode | (close) => ReactNode | – | Panel content. |
| align | "start" | "center" | "end" | "start" | Horizontal alignment. |
| side | "bottom" | "top" | "bottom" | Placement. |
| open / onOpenChange | boolean | – | Optional control. |
Keyboard interactions
| Keys | Action |
|---|---|
| EnterorSpace | Open from the trigger |
| Esc | Close and return focus |
Accessibility
- The panel is role="dialog"; the trigger has aria-expanded and aria-controls.
Motion
- Scales in from the trigger side.
Responsive behavior
- Keep panels under 320px wide for phones.
Performance
- Content renders only while open.
Notes for AI
- Use the close() render prop to close after an action.
This page as Markdown, for assistants and coding agents:
/components/popover/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt