Popover
A surface that opens beside the thing that opened it, and stays in the page while it does. Three parts: the box that holds the pair, the surface that floats over it, and the content inside.
API
Props
| prop | type | default | |
|---|---|---|---|
open? | boolean | false | whether it is open |
onClose? | () => void | — | called on a click outside or on escape |
dismissible? | boolean | true | whether those two close it |
position? | "up" | "down" | "left" | "right" | "down" | the side of the anchor it opens on |
align? | "start" | "center" | "end" | "start" | how it lines up across that side |
…rest | props of div | — | the surface is always a div |
Popover.Anchor
| prop | type | default | |
|---|---|---|---|
as? | ElementType | "div" | the element or component to render |
…rest | props of as | — | typed against the chosen element |
Popover.Body
| prop | type | default | |
|---|---|---|---|
as? | ElementType | "div" | the element or component to render |
…rest | props of as | — | typed against the chosen element |
Examples
Usage
Three components, one relationship: the anchor wraps the trigger and the surface, and the surface opens beside it. You keep the state and hand it over as open; the component gives it back through onClose whenever the page says so, so what you hold never drifts from what is on screen.
const [open, setOpen] = useState(false);
<Popover.Anchor>
<Button open={open} onClick={() => setOpen((current) => !current)}>
<Button.Label label="open popover" />
</Button>
<Popover open={open} onClose={() => setOpen(false)}>
<Popover.Body>
<p>{TEXT}</p>
<Button active onClick={() => setOpen(false)}>
<Button.Label label="close" />
</Button>
</Popover.Body>
</Popover>
</Popover.Anchor>Placement
position picks the side of the anchor, align decides how the surface lines up across that side. The two together are twelve offsets, and none of them is computed: they are the edges of the anchor, written as classes and resolved by the browser with the rest of the layout.
<Popover position="up" align="end" open={open} onClose={close}>
<Popover.Body>…</Popover.Body>
</Popover>Alignment
Across the chosen side there are three ways to sit, and which one reads as natural depends on where the anchor is: start lines the two left edges up, end the right ones, center pins the middle. Since the surface is usually wider than what opened it, alignment is what decides in which direction the extra width grows.
<Popover position="down" align="end" open={open} onClose={close}>
<Popover.Body>…</Popover.Body>
</Popover>More than it can show
The body caps its own height and scrolls what does not fit, containing the overscroll so that reaching the end does not start scrolling the page behind it. Nothing about that is the surface's business: the frame stays the size of what it holds, up to the point where the body takes over.
export const popoverBodyClassName = "flex max-h-56 min-h-0 w-64 flex-col gap-1 overflow-y-auto overscroll-contain p-2";Details
In the page, not above it
The surface is absolutely positioned against the anchor, which means it stays part of the document: it scrolls with the section that owns it, and it obeys the stacking of the page instead of escaping it. A sticky header with a higher layer keeps covering it, which is the behaviour you want from a menu that belongs to a row somewhere down the page.
The other half of that bargain is real and worth knowing: an ancestor with overflow: hidden clips it, and an ancestor with a transform becomes the box it is positioned against. Both are the price of not living in the browser's top layer, where a surface is unclippable but also unstoppable.
Which layer it sits on
The surface is one step above the layer components use internally — the label of a button sits at z-10 to clear its own ripple — so an open popover covers the controls beside it rather than being covered by whichever one comes later in the markup. Inside a container that raises the stakes, raise the surface too: the class you pass wins, because it is merged last.
<Popover open={open} onClose={close} className="z-40">
<Popover.Body>…</Popover.Body>
</Popover>Closing is a decision
While it is open the component listens for a pointer press anywhere and for escape, and calls onClose when the press lands outside both the surface and its anchor. The anchor is excluded on purpose: a press on the trigger has to reach the trigger, or a button that toggles would close and reopen in the same gesture and never look shut.
Nothing closes on its own, here or anywhere else in the kit. onClose is a request, and the popover stays open until your state says otherwise — which is what lets a step be confirmed, a change be saved, or a dismissal be refused outright.
<Popover open={open} onClose={close} dismissible={false}>
<Popover.Body>…</Popover.Body>
</Popover>What is inside, and when
The content is mounted when the popover opens and cleared once the closing animation has finished, so what you put inside neither renders behind a closed surface nor vanishes mid-exit. Between the two, the surface is hidden from the pointer and from assistive technology rather than merely transparent.
Semantics belong to the caller
A div means nothing to a screen reader, and the kit does not invent a meaning it cannot know: the same surface is a listbox under a select, a menu under a button, a dialog when it carries a form. Give it the role that fits, an id, and point the trigger at it with aria-controls and aria-expanded.