Popover
An anchored popover opened from a trigger: positioned by side and align with automatic collision flipping, it fades, scales and slides in from its side, with full focus handling and outside-click dismissal.
A panel that opens from a button and sits right next to it — a good fit for filters, date ranges and quick settings that do not deserve a full dialog. It is a compound component: Popover.Trigger, Popover.Content, Popover.Close and Popover.Arrow. The panel is placed by side and align; when it hits a viewport edge it flips to the opposite side and shifts back into view along the cross axis. It enters with a fade, a 0.95 scale and an 8px slide from its side, with the transform origin following the actual placement. Focus moves into the panel on open and back to the trigger on close, and both outside clicks and Escape dismiss it. The positioning logic lives in the same single file, with no positioning library.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add popover.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<Popover/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/popover.en.jsonOr, once registries are configured in components.json, install it as @webberui/popover.
Usage
import { Popover } from "@/components/ui/popover";
<Popover>
<Popover.Trigger>Set range</Popover.Trigger>
<Popover.Content side="bottom" align="start" aria-labelledby="range-title">
<Popover.Arrow />
<p id="range-title" className="text-sm font-medium">Report range</p>
{/* your form content */}
<Popover.Close>Done</Popover.Close>
</Popover.Content>
</Popover>Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state; managed internally when omitted (uncontrolled) |
defaultOpen | boolean | false | Initial open state in uncontrolled mode |
onOpenChange | (open: boolean) => void | — | Called when the open state changes |
modal | boolean | false | Modal mode: traps Tab focus inside the panel, locks page scroll and adds a transparent click shield behind it |
children | ReactNode | — | Popover.Trigger, Popover.Content and the other parts |
Popover.Trigger
| Field | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | When true, renders no button and merges the ref, click handler and aria attributes into its single child |
children | ReactNode | — | Trigger content |
className | string | — | Passed to the trigger and merged with the default styles via cn() |
Any other button attributes (aria-label, disabled, onClick…) are passed through to the button.
Popover.Content
| Field | Type | Default | Description |
|---|---|---|---|
side | "top" | "right" | "bottom" | "left" | "bottom" | Side of the trigger to place the panel on; flips to the opposite side when there is not enough room |
align | "start" | "center" | "end" | "center" | Alignment on the cross axis |
sideOffset | number | 4 | Gap between the trigger and the panel (px) |
alignOffset | number | 0 | Offset along the cross axis (px); positive values move inward |
collisionPadding | number | 8 | Distance kept from the viewport edges during collision detection (px) |
avoidCollisions | boolean | true | Whether to flip the side and shift the panel back into the viewport when it would overflow |
duration | number | 100 | Enter/exit animation duration (ms); also written to --wb-popover-duration |
onEscapeKeyDown | (event: KeyboardEvent) => void | — | Called on Escape; call event.preventDefault() to keep it open |
onInteractOutside | (event: PointerEvent) => void | — | Called on a pointer down outside the panel and trigger; call event.preventDefault() to keep it open |
aria-labelledby | string | — | Id of the panel's title element, read out as the dialog name |
aria-label | string | — | Dialog name when there is no visible title |
children | ReactNode | — | Panel content |
className | string | — | Passed to the panel root and merged with the default styles via cn() |
Popover.Close
| Field | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Button content; shows an × icon when omitted (with aria-label="Close") |
className | string | — | Passed to the close button and merged with the default styles via cn() |
Popover.Arrow
| Field | Type | Default | Description |
|---|---|---|---|
width | number | 10 | Arrow base width (px) |
height | number | 5 | Arrow height (px) |
className | string | — | Passed to the arrow SVG; recolor it with fill-* / stroke-* |
The PopoverSide and PopoverAlign types are exported as well.
How it works
- Positioning: the panel is portaled to
document.bodywithposition: fixed, and its coordinates are rounded to keep text crisp. On the main axis it sitssideOffsetpx outside the trigger; if it overflows and the opposite side has more room it flips, and on the cross axis it is clamped into the viewport (pinned to the left edge when it is wider than the viewport). - When it recalculates: once synchronously on open, then on size changes of the trigger and panel, window resizes and scrolling of any ancestor, throttled with
requestAnimationFrameto at most once per frame. Everything is removed on close. - Until the first measurement finishes the panel stays
visibility: hidden, so it never flashes at the top-left corner; the enter animation starts only after that. - The panel root carries
data-sideanddata-align(the actual values after flipping) plusdata-state, so you can style by direction. - The timing is exposed as
--wb-popover-duration: every CSS transition inside the panel reads it, so you can override it globally. - The arrow points at the trigger's center and stays clear of the panel's rounded corners; its fill and stroke match the panel and cover the border where they meet.
- Nested popovers: Escape closes only the topmost one, and clicks inside a child popover do not close its parent.
Accessibility
- The trigger has
aria-haspopup="dialog"andaria-expanded, and points at the panel witharia-controlswhile open; the panel isrole="dialog", named viaaria-labelledbyoraria-label, witharia-modalin modal mode - On open, focus moves to the first focusable element in the panel (or the panel itself); however it closes, focus returns to the trigger if it was still inside the panel
- Non-modal: Tab past the last element moves to the element after the trigger and closes the panel, Shift+Tab past the first returns to the trigger; modal: Tab / Shift+Tab cycle inside the panel
- Escape, a pointer down outside the panel and
Popover.Closeall close it; every button has a focus-visible ring - When the user has reduce motion turned on, only the fade remains (same duration) — no scale, no slide
Cookie Consent Banner
A React cookie consent banner with a preferences dialog: necessary/analytics/marketing category toggles, accept-all and reject-all, a consent-object callback and localStorage persistence — SSR-safe with reduced-motion support.
Tooltip
An anchored React tooltip that opens on hover or keyboard focus after a delay, switches instantly while moving within a group, fades and scales in, flips at the viewport edge, and carries an arrow pointing at its trigger.