Popover
popoverAnchors interactive contextual content to a trigger without leaving the page.
Usage
Basic usage
Click the trigger to pop up the floating layer, click outside or Esc to close; with title + description + operation area.
<Popover>
<PopoverTrigger render={<Button>Open elastic layer</Button>} />
<PopoverContent title="Hulian elastic layer" description="Click outside or Esc to close.">
<div className="flex justify-end gap-2">
<PopoverClose render={<Button variant="ghost">Cancel</Button>} />
<PopoverClose render={<Button>OK</Button>} />
</div>
</PopoverContent>
</Popover>Pop-up direction
side controls the orientation relative to the trigger (top / right / bottom / left), and the arrow automatically points to the trigger.
<>
<Popover>
<PopoverTrigger render={<Button>Bounce up</Button>} />
<PopoverContent side="top" title="Bounce up" description="side=\"top\"." />
</Popover>
<Popover>
<PopoverTrigger render={<Button>Bounce right</Button>} />
<PopoverContent side="right" title="Bounce right" description="side=\"right\"." />
</Popover>
</>Alignment
align controls edge alignment (start / center / end), and works with side to fine-tune the floating layer placement point.
<Popover>
<PopoverTrigger render={<Button>Align bottom left</Button>} />
<PopoverContent side="bottom" align="start" title="Left justified" description="align=\"start\"." />
</Popover>Edge-to-edge popup: plain + arrow={false}
Use plain when the content brings its own appearance, such as the bottom border on the search row and the padding inside the list: the inner skin div is not rendered, so children reach the edges of the popup. className="p-0" clears only the popup's own padding. The arrow is a separate switch, and a flush menu usually turns both off.
<Popover>
<PopoverTrigger render={<Button variant="outline">Choose tags</Button>} />
<PopoverContent plain arrow={false} align="start" className="w-auto p-0">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search className="size-3.5 text-muted-foreground" />
<input placeholder="Search tags" className="w-40 bg-transparent text-sm outline-none" />
</div>
<div className="py-1">{/* Tag list */}</div>
</PopoverContent>
</Popover>When to use
Use Popover for a lightweight click-triggered surface containing a title, description, a few actions, or a compact form. It closes on outside interaction or Escape. Use Tooltip for short hover text, HoverCard for rich hover content, or Dialog for a modal flow with an overlay and focus trap.
Import
import { Popover, PopoverTrigger, PopoverClose, PopoverContent } from "@hulianui/ui"Props
PopoverContent:
| Name | Type | Default | Description |
|---|---|---|---|
| side | "top"|"right"|"bottom"|"left" | "bottom" | Preferred popup side. |
| align | "start"|"center"|"end" | "center" | Alignment along the trigger. |
| sideOffset | number | 8 | Distance from the trigger in pixels. |
| anchor | Element|RefObject<Element>|VirtualElement|(() => Element|VirtualElement|null) | - | Position against something other than PopoverTrigger; with it the trigger can be omitted entirely. See below. |
| plain | boolean | false | No chrome: skip the wrapper around children (spacing plus text-sm text-foreground) so children land directly in the popup. |
| arrow | boolean | true | Whether to render the arrow pointing at the trigger. |
| className | string | - | Additional class name. |
plain and arrow: edge-to-edge popups whose content brings its own appearance
PopoverContent wraps children in a text-sm text-foreground skin element, and adds mt-2 to separate it from the header only when a title or description is present. When the popup holds one whole block that brings its own appearance and needs to reach the edges (a search row above a tag list, a Calendar panel, or a popup used as a flush menu), pair className="p-0" with plain:
<PopoverContent plain arrow={false} align="start" className="w-auto p-0">
<div className="flex items-center gap-2 border-b border-border p-2">
<Search className="size-3" />
<Input variant="cell" placeholder="Search tags" />
</div>
<div className="py-1">{/* Tag list */}</div>
</PopoverContent>p-0 clears only the popup's own padding, never the inner skin element: className lands on the popup and cannot reach inside, so do not reach in with arbitrary variants such as [&>div]:mt-0, which turns internal structure into an external contract.
arrow and plain are two independent switches, because the arrow describes the relationship between popup and trigger rather than the appearance of the content. A flush menu usually turns both off, while a plain text hint without a title needs only plain, and a full-bleed panel that should still point at its source keeps the arrow.
The same plain name means the same thing as Card's variant="plain" and the panel plain on Accordion and Collapsible: when the content brings its own appearance, the answer is no skin rather than a different skin.
anchor: when the trigger point is a coordinate rather than an element
A marker computed on a DOCX preview or a canvas, a right-click position, a point on a map: such trigger points are only a rectangle, with no DOM node that could serve as a trigger, so PopoverTrigger cannot hold them. Pass anchor a virtual element that only has to implement getBoundingClientRect(), drop the trigger entirely, and drive open yourself:
const [marker, setMarker] = useState<DOMRect | null>(null);
<div onClick={(e) => setMarker(new DOMRect(e.clientX, e.clientY, 0, 0))}>{/* Preview canvas */}</div>
<Popover open={marker != null} onOpenChange={(next) => !next && setMarker(null)}>
<PopoverContent anchor={marker && { getBoundingClientRect: () => marker }} align="start">
{/* Marker panel */}
</PopoverContent>
</Popover>Collision flipping, viewport clamping, focus management, Escape and outside-click dismissal, and aria-expanded all stay with the component, exactly the parts that should not be hand-rolled, since focus and ARIA are what a hand-rolled version drops first.
When the coordinate changes, pass a new object (or switch to the () => virtualEl function form) instead of mutating fields on the same object: positioning recomputes on anchor identity change, so mutating fields leaves the popup where it was.
The same anchor name means the same thing on HoverCard, except that the trigger stays mandatory there because the card opens on hover.
Slots
PopoverContent:
| Slot | Type | Description |
|---|---|---|
| title | ReactNode | Title. |
| description | ReactNode | Supporting copy. |
| children | ReactNode | Body and actions. |
PopoverTrigger and PopoverClose use render to supply custom trigger or close elements, for example render={<Button>…</Button>}.
Example
<Popover>
<PopoverTrigger render={<Button>Open popover</Button>} />
<PopoverContent side="bottom" align="center" title="Confirm action" description="Click outside or press Escape to close.">
<div className="flex justify-end gap-2">
<PopoverClose render={<Button variant="ghost">Cancel</Button>} />
<PopoverClose render={<Button>Confirm</Button>} />
</div>
</PopoverContent>
</Popover>Usage guidelines
- Inject trigger and close elements through
render. Do not nest another interactive element inside PopoverTrigger, which can create<button>inside<button>. - Use
anchorto position against a coordinate instead of hand-rollingcreatePortalwith your ownleft/top: that path forces you to rebuild collision flipping, viewport clamping, outside-click dismissal, Escape, and focus management, and focus plusaria-expandedare precisely what hand-rolled versions miss. - Add
plain(usually withclassName="p-0") when the popup content brings its own padding, borders, or body color. Do not reach into the internal skin element with arbitrary variants such as[&>div]:mt-0. - Combining hover opening with focus closing on a focus-managing popover can flicker: opening moves focus inside, blur closes it, and restored focus reopens it. See [[hovercard-on-focus-managing-popover-flickers-set-initial-final-focus-false]]. If adapting this component to hover behavior, set
initialFocusandfinalFocusto false.
Related
Playground
<Popover>
<PopoverTrigger render={<Button>Open elastic layer</Button>} />
<PopoverContent side="bottom" align="center" title="Hulian elastic layer">
{/* Content + <PopoverClose/> */}
</PopoverContent>
</Popover>