Popover

Click-to-open panel that closes on outside click — shown as notifications.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/popover.tsx

    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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.