bx

Popover

Displays rich content in a portal, triggered by a button.

tsx
<Popover>
  <PopoverTrigger for="dimensions">
    <Button variant="outline">Open popover</Button>
  </PopoverTrigger>
  <PopoverContent id="dimensions" className="w-80">
    <div className="grid gap-4">
      <div className="space-y-2">
        <div className="font-medium leading-none">Dimensions</div>
        <p className="text-sm text-muted-foreground">
          Set the dimensions for the layer.
        </p>
      </div>
      <div className="grid gap-2">
        <div className="grid grid-cols-3 items-center gap-4">
          <Label htmlFor="width">Width</Label>
          <Input id="width" value="100%" className="col-span-2 h-8" />
        </div>
        <div className="grid grid-cols-3 items-center gap-4">
          <Label htmlFor="height">Height</Label>
          <Input id="height" value="25px" className="col-span-2 h-8" />
        </div>
      </div>
    </div>
  </PopoverContent>
</Popover>

Popover is a floating panel anchored to a trigger that opens on click. Unlike a Dialog (centered modal, blocking backdrop), the popover is non-modal: the rest of the page stays interactive and clicking outside closes the popover.

Popover is the foundation for several other components that inherit its pattern:

  • DropdownMenu: a popover with a list of menu items.
  • HoverCard: opens on hover instead of click.
  • Tooltip: hover + minimal text content.
  • Combobox: a popover with a search input + filterable list.
  • DatePicker: a popover around a Calendar.

PRISM architecture: the trigger carries data-ui="toggle-pop" data-target="#popover-id", and the content is a <div data-overlay data-light hidden> with the matching id. data-light distinguishes popovers ("light") from dialogs ("heavy"): popovers do not block body scroll, close on outside click, and do not stack overflow locks.

Use it for: advanced filters, inline editors (color, dimensions), contextual details, short forms, entity previews.

Installation

Terminal
$ bext ui add popover

Usage

tsx
import { Popover, PopoverTrigger, PopoverContent, Button } from "@bext-stack/ui/primitives"

<Popover>
  <PopoverTrigger for="dimensions">
    <Button variant="outline">Open popover</Button>
  </PopoverTrigger>
  <PopoverContent id="dimensions" className="w-80">
    {/* free-form content: form, list, details */}
  </PopoverContent>
</Popover>

Anatomy

<Popover>
  ├── <PopoverTrigger for="pop-id">          // data-ui="toggle-pop"
  │   └── <Button>Open popover</Button>
  └── <PopoverContent id="pop-id">          // data-overlay data-light hidden
      └── ...free-form content...           // form, list, text

API Reference

Popover

Prop Type Default Description
className string — Extra classes on the wrapper, a with relative inline-block. Contains trigger + content.

PopoverTrigger

Prop Type Default Description
for string — Matches the id of the PopoverContent to open. Generates data-target="#for".
children ReactNode — Trigger element (button, link, icon). The click is dispatched to the toggle.

PopoverContent

Prop Type Default Description
id string — Matches PopoverTrigger.for.
align "start" | "center" | "end" "start" Horizontal alignment of the panel relative to the trigger. start→left-0, center→center, end→right-0.
ariaLabel string — Accessible name (aria-label) of the panel. Without it the island names the panel after its trigger button (aria-labelledby).
className string — CSS classes. Default: absolute top-full mt-2 w-72 rounded-md border bg-popover p-4 shadow-md.

Data Attributes

Attributes you can target in custom CSS to override the style for each state.

Attribute On Values Description
data-ui="toggle-pop" PopoverTrigger data-target="#id" Click opens/closes the popover.
data-state PopoverTrigger "open" | "closed" Set by the island, together with aria-haspopup="dialog", aria-expanded and aria-controls on the button inside.
data-overlay data-light PopoverContent hidden by default The data-light attribute distinguishes popovers from dialogs: the island closes them on outside click without stacking the body overflow lock.

Keyboard

Key Action
Enter / Space On the trigger: opens the popover and moves focus to its first focusable element (or to the panel), or closes it. On an inner button: activates.
Tab / Shift+Tab Moves through the content, then on to the rest of the page: focus is not trapped.
Escape Closes the popover and returns focus to the trigger.

Accessibility

PopoverTrigger is a <span> around your button. The island sets aria-haspopup="dialog", aria-controls and aria-expanded on that button and keeps aria-expanded in sync on every open and close, including a close by outside click or Escape. Put a real <button> or link inside the trigger: plain text gets no ARIA state and cannot be reached with the keyboard.

PopoverContent has role="dialog" without aria-modal. It is named by its trigger button through aria-labelledby, or by the ariaLabel prop.

Focus: on open, focus moves to the first focusable element of the panel, or to the panel itself when it has none. The popover does not trap focus: Tab can leave it. This is intentional, since the popover is non-modal. Escape closes it and returns focus to the trigger; an outside click closes it and leaves focus where the click put it.

Use Dialog instead when: the content is a critical form (2+ required fields), an action must be blocking, or the content needs a backdrop.

Examples

Minimal content (info)

tsx
<Popover>
  <PopoverTrigger for="info">
    <Button variant="outline">Info</Button>
  </PopoverTrigger>
  <PopoverContent id="info" className="w-64">
    <p className="text-sm text-muted-foreground">
      This is a short piece of contextual information that lives in a popover.
    </p>
  </PopoverContent>
</Popover>

Aligned end (right of the trigger)

tsx
<div className="flex justify-end w-full">
  <Popover>
    <PopoverTrigger for="menu">
      <Button variant="outline">Right-aligned</Button>
    </PopoverTrigger>
    <PopoverContent id="menu" align="end" className="w-64">
      <p className="text-sm">align="end": the popover aligns to the right edge of the trigger.</p>
    </PopoverContent>
  </Popover>
</div>
Try it live
Open in play

Edit this component live in the bext playground. The PRISM + signals version is the bext-native idiom: fine-grained reactivity, no virtual DOM.

src/app/page.tsx 2 files · signals · runs in your browser
Pure PRISM + real bext signals: fine-grained, no re-render.