Popover
A flexible, dependency‑free popover built for @libdev‑ui. It positions a floating card relative to an anchor (or the trigger when no anchor is provided), renders through a portal to document.body, and supports the standard 12 placements (top, top-start, top-end, right, right-start, right-end, bottom, bottom-start, bottom-end, left, left-start, left-end).
The popover manages focus, outside clicks, and Escape key by default. The arrow is drawn with pure CSS and is clamped away from rounded corners for a clean look.
Import
import {
PopoverForm,
PopoverTrigger,
PopoverAnchor,
PopoverCard,
PopoverClose,
PopoverArrow,
} from "@libdev-ui/base";
Demo
- TypeScript
- JavaScript
import React from "react";
import {
PopoverForm,
PopoverTrigger,
PopoverAnchor,
PopoverCard,
PopoverClose,
PopoverArrow,
Button,
Text,
Box,
} from "@libdev-ui/base";
export default function BasicPopover(): JSX.Element {
return (
<PopoverForm placement="bottom">
<PopoverTrigger>
<Button>Open</Button>
</PopoverTrigger>
{/* Ако липсва Anchor, за референтен елемент се използва Trigger-ът */}
<PopoverAnchor />
<PopoverCard style={{ padding: 16, maxWidth: 320 }}>
<Box
sl={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 8,
}}
>
<strong>Title</strong>
<PopoverClose>✕</PopoverClose>
</Box>
<Text style={{ marginBottom: 12 }}>Any content goes here.</Text>
<Button variant="soft" color="primary">
Action
</Button>
<PopoverArrow size={8} />
</PopoverCard>
</PopoverForm>
);
}
import {
PopoverForm,
PopoverTrigger,
PopoverAnchor,
PopoverCard,
PopoverClose,
PopoverArrow,
Button,
Text,
Box,
} from "@libdev-ui/base";
export default function BasicPopover() {
return (
<PopoverForm placement="bottom">
<PopoverTrigger>
<Button>Open</Button>
</PopoverTrigger>
{/* Ако липсва Anchor, за референтен елемент се използва Trigger-ът */}
<PopoverAnchor />
<PopoverCard style={{ padding: 16, maxWidth: 320 }}>
<Box sl={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 8 }}>
<strong>Title</strong>
<PopoverClose>✕</PopoverClose>
</Box>
<Text style={{ marginBottom: 12 }}>Any content goes here.</Text>
<Button variant="soft" color="primary">Action</Button>
<PopoverArrow size={8} />
</PopoverCard>
</PopoverForm>
);
}
Usage
<PopoverForm>
<PopoverTrigger>…</PopoverTrigger>
<PopoverAnchor /> {/* optional; falls back to Trigger if omitted */}
<PopoverCard>
…content…
<PopoverClose>✕</PopoverClose>
<PopoverArrow />
</PopoverCard>
</PopoverForm>
Quick start
import {
PopoverForm,
PopoverTrigger,
PopoverAnchor,
PopoverCard,
PopoverClose,
PopoverArrow,
Button,
Text,
Box,
} from "@libdev-ui/base";
export default function BasicPopover() {
return (
<PopoverForm placement="bottom">
<PopoverTrigger>
<Button>Open</Button>
</PopoverTrigger>
{/* Ако липсва Anchor, за референтен елемент се използва Trigger-ът */}
<PopoverAnchor />
<PopoverCard style={{ padding: 16, maxWidth: 320 }}>
<Box
sl={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 8,
}}
>
<strong>Title</strong>
<PopoverClose>✕</PopoverClose>
</Box>
<Text style={{ marginBottom: 12 }}>Any content goes here.</Text>
<Button variant="soft" color="primary">
Action
</Button>
<PopoverArrow size={8} />
</PopoverCard>
</PopoverForm>
);
}
Controlled vs uncontrolled
- Uncontrolled: omit
open; usedefaultOpenand let the component manage its own state. - Controlled: pass
openandonOpenChangeto fully control visibility.
function Controlled() {
const [open, setOpen] = React.useState(false);
return (
<PopoverForm open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<Button onClick={() => setOpen((o) => !o)}>
{open ? "Close" : "Open"}
</Button>
</PopoverTrigger>
<PopoverAnchor />
<PopoverCard style={{ padding: 12 }}>
controlled content
<PopoverClose>✕</PopoverClose>
<PopoverArrow />
</PopoverCard>
</PopoverForm>
);
}
Placement
placement accepts the following values:
top | top-start | top-end |
right | right-start | right-end |
bottom | bottom-start | bottom-end |
left | left-start | left-end
*-startand*-endalign the card along the secondary axis.- The card is clamped within the viewport with a small padding.
- The arrow is automatically clamped away from rounded corners and has a small guard near the extremes, so it never hugs the very edge.
Accessibility
- The trigger receives
aria-haspopup="dialog"and managesaria-expandedwhile open. - The card uses
role="dialog"by default (can be changed via theroleprop). Escapecloses the popover (unlessdisableOutsideCloseis true).- Focus returns to the trigger when the popover closes.
API
<PopoverForm />
Container that provides context, state, positioning and event wiring.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controls visibility (controlled mode). |
defaultOpen | boolean | false | Initial visibility (uncontrolled mode). |
onOpenChange | (open: boolean) => void | — | Called when open state changes. |
placement | 'top' | 'top-start' | 'top-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'bottom' | Where to place the card relative to the anchor/trigger. |
offset | number | 8 | Gap between the card and the anchor. Arrow size is handled separately in CSS. |
disableOutsideClose | boolean | false | If true, disables closing via outside click & Escape. |
children | ReactNode | — | Composition children (Trigger, Anchor, Card, etc.). |
Behavior notes
- If
<PopoverAnchor />is not rendered or has zero size (self-closing), the trigger is used as the reference element. - The card is rendered into
document.bodyvia a portal and isposition: fixedfor stable positioning on scroll. - Positioning is recomputed on open, scroll, resize, and while the card resizes.
<PopoverTrigger />
Wraps your interactive element that toggles the popover.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | true | If true, clones the single child and attaches handlers/refs (prevents nested buttons). |
children | ReactElement | — | Exactly one element to serve as the trigger. |
sl | StyleLike | — | Optional style system prop forwarded through the lib’s compileStyle. |
When
asChild={false}, a semantic wrapper<span role="button" tabIndex={0}>is used to avoid nested<button>elements. Keyboard interaction (Enter/Space) is supported in that case.
<PopoverAnchor />
Optional anchor element. If omitted, the popover uses the trigger as anchor.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | When true, does not render a wrapper; instead it clones the only child to grab its ref. |
children | ReactElement | null | null | The element to act as the anchor. If null, falls back to the trigger. |
sl | StyleLike | — | Optional style system prop. |
<PopoverCard />
The floating card panel. Handles its own positioning and portals to document.body.
| Prop | Type | Default | Description |
|---|---|---|---|
role | string | "dialog" | ARIA role for the card. |
className | string | — | Custom class. |
style | React.CSSProperties | — | Inline styles (top/left are managed internally). |
sl | StyleLike | — | Style system prop. |
children | ReactNode | — | Card content. |
...layoutProps | any | — | Any additional props are forwarded to the styled root. |
Implementation details
- Uses a double
requestAnimationFrameon open to ensure layout is available before measuring. - Repositions on
scroll/resizeand when the card ordocument.bodyresizes. - Applies
data-placementattribute for styling hooks.
<PopoverClose />
A convenience button that closes the popover.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | If true, clones the single child and wires onClick. |
children | ReactNode | "✕" | Button content. |
onClick | (e: MouseEvent) => void | — | Optional; user handler composed with the internal closer. |
...rest | button props | — | Forwarded to the <button>. |
<PopoverArrow />
Renders the arrow and positions it along the relevant edge of the card. The arrow is drawn with two border triangles (shadow + fill) and is clamped away from rounded corners; on vertical sides it also has a small bottom guard to avoid drifting too close to the bottom edge while scrolling.
| Prop | Type | Default | Description |
|---|---|---|---|
size | number | 8 | Arrow size (edge to tip). |
sl | StyleLike | — | Style system prop for advanced customization. |
style | React.CSSProperties | — | Inline style (usually you don’t need to set top/left manually). |
...rest | div props | — | Forwarded to the styled arrow root. |
Styling hooks
- The arrow root receives
data-side="top" \| "bottom" \| "left" \| "right"depending on which edge of the card the arrow attaches to. Use this to provide theme‑specific decorations.
Theming & customization
- All container components accept the
slstyle‑engine prop which passes through the design system’scompileStylehelper. - The arrow inherits its background from the card and uses a subtle drop shadow; change CSS variables (
--ld-surface,--ld-fg,--ld-radius-lg,--ld-shadow-lg) or override the styled parts if needed. - Because the card is
position: fixedwithin a portal, it is unaffected by parent transforms and scroll containers—positioning remains consistent.
Caveats
- Avoid giving the anchor
display: none; the popover relies on its DOMRect. If the anchor is zero‑sized, the trigger will be used instead. - Don’t nest native interactive elements (e.g.,
<button>inside another<button>). UseasChildon the trigger to attach events to your own element.