Popover
A button-anchored floating panel for rich content — forms, settings, previews, and pickers. Unlike a Tooltip, a Popover is interactive and is triggered by click, not hover.
Preview
When to use
| Component | Trigger | Content | Blocking |
|---|---|---|---|
| Popover | Click | Rich interactive content | No |
| Tooltip | Hover / focus | Short supplementary label | No |
| Dropdown Menu | Click | List of commands/actions | No |
| Modal | Explicit action | Critical task requiring full attention | Yes |
Anatomy
| Element | Spec |
|---|---|
| Panel surface | blur(24px) saturate(160%), --surface at 94% opacity, --shadow-sheet, 1px border |
| Specular top edge | 1px gradient line: transparent → white/15% → transparent |
| Header | 13px semibold title, close button (X), border-bottom: --border-subtle, px-4 py-3 |
| Body | px-4 py-3, content area — forms, toggles, pickers, text |
| Footer (optional) | border-top: --border-subtle, px-4 py-3, flex justify-end, action buttons |
| Width | 240–400px depending on content. Fixed width, not full-screen on desktop |
| Offset from trigger | 8px gap (mt-2 / mb-2) |
| Border radius | --radius-lg (14px) |
Implementation
Popover.tsx
tsx
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib";
interface PopoverProps {
content: React.ReactNode;
trigger: React.ReactNode;
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
width?: number | string;
onOpenChange?: (open: boolean) => void;
}
export function Popover({
content,
trigger,
side = "bottom",
align = "start",
width = 320,
onOpenChange,
}: PopoverProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => {
setOpen(false);
onOpenChange?.(false);
}, [onOpenChange]);
const toggle = () => {
const next = !open;
setOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
if (e instanceof MouseEvent) {
if (triggerRef.current?.contains(e.target as Node)) return;
if (panelRef.current?.contains(e.target as Node)) return;
}
close();
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", handler);
};
}, [open, close]);
const verticalClass =
side === "top" ? "bottom-full mb-2" :
side === "left" ? "right-full mr-2 top-0" :
side === "right" ? "left-full ml-2 top-0" :
"top-full mt-2";
const alignClass =
align === "end" ? "right-0" :
align === "center" ? "left-1/2 -translate-x-1/2" :
"left-0";
return (
<div className="relative inline-flex">
<button ref={triggerRef} onClick={toggle} aria-expanded={open} aria-haspopup="dialog">
{trigger}
</button>
{open && (
<div
ref={panelRef}
role="dialog"
aria-modal="false"
className={cn("absolute z-50 rounded-[var(--radius-lg)] border border-[rgb(var(--border))] overflow-hidden", verticalClass, alignClass)}
style={{
width,
background: "rgb(var(--surface) / 0.94)",
backdropFilter: "blur(24px) saturate(160%)",
WebkitBackdropFilter: "blur(24px) saturate(160%)",
boxShadow: "var(--shadow-sheet), inset 0 1px 0 rgba(255,255,255,0.08)",
}}
>
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent pointer-events-none" />
{content}
</div>
)}
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
content | ReactNode | — | Rich content rendered inside the popover panel. Can include any React node — forms, settings, previews. |
trigger | ReactNode | — | The trigger element. Rendered as a button that toggles the popover on click. |
side | "top" | "bottom" | "left" | "right" | "bottom" | Preferred placement relative to the trigger. Flips automatically if it would overflow the viewport. |
align | "start" | "center" | "end" | "start" | Horizontal alignment of the panel relative to the trigger. |
width | number | string | 320 | Panel width in pixels, or any CSS width value. |
onOpenChange | (open: boolean) => void | — | Called when the popover opens or closes. |
Accessibility
- →Set aria-haspopup='dialog' and aria-expanded on the trigger. Update aria-expanded when the panel opens and closes.
- →Set role='dialog' and aria-modal='false' on the panel. A popover is non-modal — the user can still interact with the page behind it.
- →Escape always closes the panel and returns focus to the trigger button.
- →Move focus into the panel on open — focus the first interactive element (input, toggle, close button).
- →Trap focus inside the panel only when aria-modal='true' (full modal behavior). For non-modal popovers, allow Tab to leave the panel naturally.
- →Provide a visible close button (×) for users who cannot press Escape.
- →Never put content required to complete a task inside a popover — use a Modal for blocking, critical flows.