Popover
Click-to-open panel that closes on outside click — shown as notifications.
components/ui/popover.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Bell, Check } from "lucide-react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
type PopoverProps = {
trigger: (props: { open: boolean; toggle: () => void; id: string }) => ReactNode;
children: ReactNode;
align?: "start" | "end";
};
export function Popover({ trigger, children, align = "end" }: PopoverProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const id = useId();
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={ref} className="relative inline-block">
{trigger({ open, toggle: () => setOpen((o) => !o), id })}
<AnimatePresence>
{open && (
<motion.div
id={id}
role="dialog"
initial={{ opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }}
transition={{ duration: 0.16 }}
className={`absolute top-full z-50 mt-2 w-80 origin-top rounded-2xl border border-zinc-200 bg-white p-2 shadow-xl ${align === "end" ? "right-0" : "left-0"}`}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
const notes = [
{ who: "Priya", what: "commented on Homepage v2", when: "2m" },
{ who: "Daniel", what: "approved your pull request", when: "1h" },
{ who: "Elena", what: "invited you to Growth team", when: "3h" },
];
export default function Demo() {
const [read, setRead] = useState(false);
return (
<div className="flex h-72 w-full max-w-sm justify-end">
<Popover
trigger={({ open, toggle, id }) => (
<button onClick={toggle} aria-expanded={open} aria-controls={id} aria-label="Notifications" className="relative grid size-10 place-items-center rounded-xl border border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50">
<Bell className="size-5" />
{!read && <span className="absolute right-2 top-2 size-2 rounded-full bg-rose-500 ring-2 ring-white" />}
</button>
)}
>
<div className="flex items-center justify-between px-3 py-2">
<p className="text-sm font-semibold text-zinc-900">Notifications</p>
<button onClick={() => setRead(true)} className="flex items-center gap-1 text-xs text-blue-600 hover:underline">
<Check className="size-3" /> Mark all read
</button>
</div>
<ul>
{notes.map((n) => (
<li key={n.who} className="flex gap-3 rounded-xl px-3 py-2.5 hover:bg-zinc-50">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-zinc-900 text-xs font-medium text-white">{n.who[0]}</span>
<p className="flex-1 text-sm text-zinc-600">
<span className="font-medium text-zinc-900">{n.who}</span> {n.what}
</p>
<span className="text-xs text-zinc-400">{n.when}</span>
</li>
))}
</ul>
</Popover>
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/popover.tsxcomponents/ui/popover.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { Bell, Check } from "lucide-react"; import { useEffect, useId, useRef, useState, type ReactNode } from "react"; type PopoverProps = { trigger: (props: { open: boolean; toggle: () => void; id: string }) => ReactNode; children: ReactNode; align?: "start" | "end"; }; export function Popover({ trigger, children, align = "end" }: PopoverProps) { const [open, setOpen] = useState(false); const ref = useRef<HTMLDivElement>(null); const id = useId(); useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => !ref.current?.contains(e.target as Node) && setOpen(false); const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); document.addEventListener("pointerdown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); return ( <div ref={ref} className="relative inline-block"> {trigger({ open, toggle: () => setOpen((o) => !o), id })} <AnimatePresence> {open && ( <motion.div id={id} role="dialog" initial={{ opacity: 0, y: -6, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -4, scale: 0.98 }} transition={{ duration: 0.16 }} className={`absolute top-full z-50 mt-2 w-80 origin-top rounded-2xl border border-zinc-200 bg-white p-2 shadow-xl ${align === "end" ? "right-0" : "left-0"}`} > {children} </motion.div> )} </AnimatePresence> </div> ); } const notes = [ { who: "Priya", what: "commented on Homepage v2", when: "2m" }, { who: "Daniel", what: "approved your pull request", when: "1h" }, { who: "Elena", what: "invited you to Growth team", when: "3h" }, ]; export default function Demo() { const [read, setRead] = useState(false); return ( <div className="flex h-72 w-full max-w-sm justify-end"> <Popover trigger={({ open, toggle, id }) => ( <button onClick={toggle} aria-expanded={open} aria-controls={id} aria-label="Notifications" className="relative grid size-10 place-items-center rounded-xl border border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50"> <Bell className="size-5" /> {!read && <span className="absolute right-2 top-2 size-2 rounded-full bg-rose-500 ring-2 ring-white" />} </button> )} > <div className="flex items-center justify-between px-3 py-2"> <p className="text-sm font-semibold text-zinc-900">Notifications</p> <button onClick={() => setRead(true)} className="flex items-center gap-1 text-xs text-blue-600 hover:underline"> <Check className="size-3" /> Mark all read </button> </div> <ul> {notes.map((n) => ( <li key={n.who} className="flex gap-3 rounded-xl px-3 py-2.5 hover:bg-zinc-50"> <span className="grid size-8 shrink-0 place-items-center rounded-full bg-zinc-900 text-xs font-medium text-white">{n.who[0]}</span> <p className="flex-1 text-sm text-zinc-600"> <span className="font-medium text-zinc-900">{n.who}</span> {n.what} </p> <span className="text-xs text-zinc-400">{n.when}</span> </li> ))} </ul> </Popover> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.