Overlays & Feedback

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.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

100
4
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
openboolean—Controlled open state; managed internally when omitted (uncontrolled)
defaultOpenbooleanfalseInitial open state in uncontrolled mode
onOpenChange(open: boolean) => void—Called when the open state changes
modalbooleanfalseModal mode: traps Tab focus inside the panel, locks page scroll and adds a transparent click shield behind it
childrenReactNode—Popover.Trigger, Popover.Content and the other parts

Popover.Trigger

FieldTypeDefaultDescription
asChildbooleanfalseWhen true, renders no button and merges the ref, click handler and aria attributes into its single child
childrenReactNode—Trigger content
classNamestring—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

FieldTypeDefaultDescription
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
sideOffsetnumber4Gap between the trigger and the panel (px)
alignOffsetnumber0Offset along the cross axis (px); positive values move inward
collisionPaddingnumber8Distance kept from the viewport edges during collision detection (px)
avoidCollisionsbooleantrueWhether to flip the side and shift the panel back into the viewport when it would overflow
durationnumber100Enter/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-labelledbystring—Id of the panel's title element, read out as the dialog name
aria-labelstring—Dialog name when there is no visible title
childrenReactNode—Panel content
classNamestring—Passed to the panel root and merged with the default styles via cn()

Popover.Close

FieldTypeDefaultDescription
childrenReactNode—Button content; shows an × icon when omitted (with aria-label="Close")
classNamestring—Passed to the close button and merged with the default styles via cn()

Popover.Arrow

FieldTypeDefaultDescription
widthnumber10Arrow base width (px)
heightnumber5Arrow height (px)
classNamestring—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.body with position: fixed, and its coordinates are rounded to keep text crisp. On the main axis it sits sideOffset px 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 requestAnimationFrame to 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-side and data-align (the actual values after flipping) plus data-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" and aria-expanded, and points at the panel with aria-controls while open; the panel is role="dialog", named via aria-labelledby or aria-label, with aria-modal in 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.Close all 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