Popover
A button-triggered popover panel built on popover + CSS Anchor Positioning — the base for dropdown-menu compositions
Installation
Usage
Basic Usage
Popover is a button + popover base, not a menu: the trigger opens a panel whose content is entirely yours. In the default click mode, clicking the trigger toggles the panel (ArrowDown / Space on the trigger also open it). The panel closes on Escape pressed while focus is inside it, or on mousedown outside it. With mode="hover", the panel opens on pointer enter or trigger focus and closes after a short delay once the pointer leaves both. Compose it with Menu to build a dropdown menu, or with Checkbox to build a MultiSelect.
Selected: -
"use client";
import { useState } from "react";
import { Popover } from "@/ui";
import { Button } from "@/ui";
const actions = ["Rename", "Duplicate", "Delete"];
export default function PopoverBasicDemo() {
const [selected, setSelected] = useState<string | null>(null);
return (
<div className="flex flex-col items-center gap-3">
<Popover
trigger="Open"
classNames={{ trigger: [Button.className.base, Button.className.variant.primary] }}
>
<div className="flex flex-col">
{actions.map((action) => (
<button
key={action}
type="button"
onClick={() => setSelected(action)}
className={
"px-2 py-1.5 text-left text-sm font-semibold rounded-sm hover:bg-hover" +
(action === "Delete" ? " text-danger" : "")
}
>
{action}
</button>
))}
</div>
</Popover>
<p className="text-sm text-muted-foreground">Selected: {selected ?? "-"}</p>
</div>
);
}
Alignment
Beyond the built-in alignX (start / center / end), any CSS Anchor Positioning positionArea can be set through styles.content, with flip-block, flip-inline fallbacks enabled by default.
Position Area
Align X (sidebar)
"use client";
import { Popover } from "@/ui";
import { Button } from "@/ui";
function ActionList() {
return (
<div className="flex flex-col">
{["Item 1", "Item 2", "Item 3"].map((label) => (
<button
key={label}
type="button"
className="px-2 py-1.5 text-left text-sm font-semibold rounded-sm hover:bg-hover"
>
{label}
</button>
))}
</div>
);
}
export default function PopoverAlignmentDemo() {
return (
<div className="flex flex-col gap-8">
<div>
<h3 className="text-sm font-medium mb-4">Position Area</h3>
<div className="flex gap-4">
<Popover
styles={{ content: { positionArea: "bottom span-right", justifySelf: "start" } }}
classNames={{ trigger: [Button.className.base, Button.className.variant.primary] }}
trigger="span-right"
>
<ActionList />
</Popover>
<Popover
classNames={{ trigger: [Button.className.base, Button.className.variant.primary] }}
trigger="bottom span-all"
>
<ActionList />
</Popover>
<Popover
styles={{ content: { positionArea: "bottom span-left", justifySelf: "end" } }}
classNames={{ trigger: [Button.className.base, Button.className.variant.primary] }}
trigger="span-left"
>
<ActionList />
</Popover>
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-4">Align X (sidebar)</h3>
<div className="flex gap-4">
<Popover
alignX="start"
classNames={{ trigger: [Button.className.base, Button.className.variant.primary] }}
trigger="alignX start"
>
<ActionList />
</Popover>
<Popover
alignX="end"
classNames={{ trigger: [Button.className.base, Button.className.variant.primary] }}
trigger="alignX end"
>
<ActionList />
</Popover>
</div>
</div>
</div>
);
}
Composable Components
Assemble the parts manually for full control over the trigger wiring — the demo drives showPopover() / hidePopover() on open changes and closes on outside mousedown, while the Escape handling comes built into PopoverContent.
"use client";
import { useState, useRef, useEffect } from "react";
import { Button, PopoverContent } from "@/ui";
const anchorName = "--popover-custom-demo";
export default function Demo() {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
if (open) {
if (!panel.matches(":popover-open")) panel.showPopover();
} else {
if (panel.matches(":popover-open")) panel.hidePopover();
}
}, [open]);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (triggerRef.current?.contains(target)) return;
if (panelRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const close = () => setOpen(false);
return (
<>
<Button ref={triggerRef} style={{ anchorName }} onClick={() => setOpen(!open)}>
Open Custom Popover
</Button>
<PopoverContent
ref={panelRef}
style={{
positionAnchor: anchorName,
positionArea: "bottom span-all",
justifySelf: "center",
margin: "4px 0 0",
positionTryFallbacks: "flip-block, flip-inline",
}}
>
<button
className="w-full text-left px-2 py-1.5 text-sm font-semibold rounded-sm hover:bg-hover"
onClick={close}
>
Profile
</button>
<button
className="w-full text-left px-2 py-1.5 text-sm font-semibold rounded-sm hover:bg-hover"
onClick={close}
>
Settings
</button>
<button
className="w-full text-left px-2 py-1.5 text-sm font-semibold rounded-sm hover:bg-hover text-danger"
onClick={close}
>
Logout
</button>
</PopoverContent>
</>
);
}
API Reference
High-level Components
Popover
Renders a native trigger button (type="button", aria-haspopup, aria-expanded) plus the floating panel, positioned with CSS Anchor Positioning.
| Prop | Type | Default | Description |
|---|---|---|---|
trigger | React.ReactNode | - | Content of the trigger button |
children | React.ReactNode | - | Panel content |
open | boolean | - | Controlled panel state |
defaultOpen | boolean | false | Initial panel state (uncontrolled) |
onOpenChange | (open: boolean) => void | - | Fired whenever the panel opens or closes |
alignX | "start" | "end" | "center" | center | Horizontal alignment of the panel relative to the trigger |
mode | "click" | "hover" | click | Trigger interaction mode; hover opens on pointer enter / trigger focus and closes after a delayed pointer leave |
classNames | { trigger?: ClassNameValue; content?: ClassNameValue } | - | Custom class names per slot |
styles | { trigger?: React.CSSProperties; content?: React.CSSProperties } | - | Inline styles per slot — e.g. styles.content.positionArea overrides alignX |
Closing paths: in click mode the trigger click toggles the panel; the panel closes on Escape (while focus is inside the panel) and on mousedown outside it; in hover mode the panel closes after hoverDelayClose ms once the pointer leaves both trigger and panel.
Hooks
usePopoverTrigger
Wires a custom trigger element and panel to the popover state — the composite Popover is built on it. Apply anchorName as the trigger's anchor-name style and as the panel's positionAnchor so the panel aligns to the trigger.
Options (UsePopoverTriggerOptions):
| Option | Type | Default | Description |
|---|---|---|---|
open | boolean | - | Current panel state |
onOpenChange | (v: boolean) => void | - | State updater the hook calls to open or close |
mode | "click" | "hover" | click | Interaction mode |
hoverDelayOpen | number | 0 | Delay in ms before opening after pointer enter (hover) |
hoverDelayClose | number | 200 | Delay in ms before closing after the pointer leaves |
Returns:
| Member | Type | Description |
|---|---|---|
triggerProps | object | Spread on the trigger element: aria-haspopup, aria-expanded, the anchor-name style, and click / hover / focus / keyboard handlers |
contentProps | object | Spread on the panel: cancels the pending close timer on pointer enter and schedules it on pointer leave (hover mode) |
clearTimer | () => void | Cancels any pending hover open/close timer |
scheduleOpen | () => void | Opens the panel, honoring hoverDelayOpen |
scheduleClose | () => void | Closes the panel after hoverDelayClose ms |
anchorName | string | Generated CSS anchor name (e.g. --popover-trigger-abc123) |
Composable Components
PopoverContent
The floating panel: a popover="manual" div with pre-wired panel look (border, shadow, scrollable up to max-h-96). Toggling open runs showPopover() / hidePopover(); Escape inside the panel or a mousedown outside it calls onOpenChange(false). A consumer onKeyDown composes with the built-in Escape handling — call preventDefault() to skip it.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | - | Controls the panel; true calls showPopover(), false calls hidePopover() |
onOpenChange | (open: boolean) => void | - | Called when Escape is pressed inside the panel or a mousedown lands outside it |
alignX | "start" | "end" | "center" | center | Horizontal alignment via CSS Anchor Positioning positionArea |
className | ClassNameValue | - | Custom class names |
style | React.CSSProperties | - | Inline styles merged over the built-in anchor styles (positionTryFallbacks: "flip-block, flip-inline") |
ref | React.Ref<HTMLDivElement> | - | Forwards to the panel div |
...props | React.ComponentProps<"div"> | - | Supports all native div props (except className), such as role, id, onKeyDown |