# Popover

Anchored panel that scales from its trigger. Closes on outside click and Escape, and returns focus.

**Category:** Overlays  
**Exports:** `Popover`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/popover.json
```
The file lands at `components/ui/popover.tsx`; import from `@/components/ui/popover`.

## API — Popover
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `trigger` | `ReactElement` | — |  |
| `open / onOpenChange` | `boolean / (open: boolean) => void` | — |  |
| `align` | `"start" \| "center" \| "end"` | `"center"` |  |
| `children` | `ReactNode \| ((close: () => void) => ReactNode)` | — |  |

## Example — Popover

```tsx
import { Popover } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

export default function PopoverDemo({ align = "center", label = "Share project" }: { align?: "start" | "center" | "end"; label?: string }) {
  return (
    <div className="pb-56">
      <Popover align={align} trigger={<Button variant="outline">{label}</Button>}>
        {(close) => (
          <div className="grid gap-4">
            <div>
              <p className="font-medium">Invite people</p>
              <p className="text-[13px] text-muted-foreground">They'll get edit access.</p>
            </div>
            <Input aria-label="Email address" type="email" placeholder="name@company.com" />
            <Button onClick={close}>Send invite</Button>
          </div>
        )}
      </Popover>
    </div>
  );
}
```

## Source — components/ui/popover.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type PopoverProps = {
  trigger: React.ReactElement<Record<string, unknown>>;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  align?: "start" | "center" | "end";
  className?: string;
  children: React.ReactNode | ((close: () => void) => React.ReactNode);
};

const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';

export function Popover({ trigger, open: openProp, onOpenChange, align = "center", className, children }: PopoverProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState(false);
  const open = openProp ?? inner;
  const rootRef = React.useRef<HTMLDivElement>(null);
  const panelRef = React.useRef<HTMLDivElement>(null);

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setInner(next);
      onOpenChange?.(next);
    },
    [openProp, onOpenChange],
  );
  const close = React.useCallback(() => {
    setOpen(false);
    (rootRef.current?.querySelector("[data-popover-trigger]") as HTMLElement | null)?.focus();
  }, [setOpen]);

  React.useEffect(() => {
    if (!open) return;
    requestAnimationFrame(() => (panelRef.current?.querySelector(FOCUSABLE) as HTMLElement | null)?.focus());
    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);
    };
  }, [open, setOpen, close]);

  const position = {
    start: "left-0 origin-top-left",
    center: "left-1/2 -translate-x-1/2 origin-top",
    end: "right-0 origin-top-right",
  }[align];

  return (
    <div ref={rootRef} className="relative inline-flex">
      {React.cloneElement(trigger, {
        "data-popover-trigger": "",
        "aria-haspopup": "dialog",
        "aria-expanded": open,
        "aria-controls": id,
        onClick: () => setOpen(!open),
      })}
      <AnimatePresence>
        {open && (
          <div className={cn("absolute top-full z-50 pt-2", position)}>
            <motion.div
              ref={panelRef}
              id={id}
              role="dialog"
              initial={{ opacity: 0, scale: reduce ? 1 : 0.94, y: reduce ? 0 : -6, filter: "blur(4px)" }}
              animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
              exit={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : -4, filter: "blur(2px)", transition: { duration: 0.12 } }}
              transition={{ type: "spring", stiffness: 520, damping: 32 }}
              className={cn(
                "w-72 rounded-2xl border bg-popover p-4 text-popover-foreground shadow-[0_20px_50px_-12px_rgb(0_0_0/0.4)]",
                className,
              )}
              style={{ transformOrigin: "inherit" }}
            >
              {typeof children === "function" ? children(close) : children}
            </motion.div>
          </div>
        )}
      </AnimatePresence>
    </div>
  );
}
```