Popover

A non-modal panel anchored to its trigger, rendered as a <div popover> in the top layer. The browser handles stacking, light dismiss and Escape; CSS anchor positioning handles the placement.

import { Popover } from '@apostel/bedrock'
// or, for a veto: import { Popover } from '@apostel/bedrock/controlled'
Loading demo…
Sourcepopover.tsx
import { Popover } from '../../src/index'

/**
 * `side` and `align` become CSS anchor positioning, so the panel follows the
 * trigger on scroll with no listener and no measuring. Escape and clicking
 * outside are the platform's light dismiss.
 */
export default function PopoverDemo() {
  return (
    <Popover.Root>
      <Popover.Trigger>Filters</Popover.Trigger>
      <Popover.Content side="bottom" align="start" sideOffset={8}>
        <div className="demo-row">
          <label>
            <input type="checkbox" defaultChecked /> Open
          </label>
        </div>
        <div className="demo-row">
          <label>
            <input type="checkbox" /> Archived
          </label>
        </div>
        <Popover.Close>Done</Popover.Close>
      </Popover.Content>
    </Popover.Root>
  )
}

Anatomy

<Popover.Root>
  <Popover.Trigger />
  <Popover.Anchor />
  <Popover.Content>
    <Popover.Close />
  </Popover.Content>
</Popover.Root>

No Portal. A popover is in the top layer, so it paints above everything regardless of where it sits in the tree or what its ancestors set overflow to.

Popover.Root

Renders no element.

prop type notes
kind 'auto' | 'manual' | 'hint' Defaults to auto.
onOpenChange (open: boolean) => void Reports; cannot refuse.

For an open prop React can refuse with, import from @apostel/bedrock/controlled. See controlled state.

kind is named for behaviour, not for the popover attribute it currently maps onto one-for-one:

value behaviour
auto Light-dismisses, and closes other auto popovers when it opens.
manual Neither. You close it yourself.
hint Layers above an open menu instead of closing it, and falls back to auto where the engine has no hint. See browser support.

The fallback is asked for, not assumed. popover is an enumerated attribute whose invalid-value default is manual, so writing hint at an engine that has never heard of it does not degrade to auto: it produces a popover that neither light dismiss nor Escape reaches. kind="hint" therefore renders popover="auto" on every engine that would not honour it.

There is no defaultOpen. A popover cannot be shown before its element is connected, and calling showPopover() from a mount effect is exactly the class of thing this library exists to delete. Use open from /controlled instead.

Popover.Trigger

Renders <button type="button" commandfor command="toggle-popover">.

prop type notes
asChild boolean The child must render a <button>. Enforced at mount.

aria-expanded is not written by hand. Chrome gives a popover invoker that attribute implicitly, and writing it too would mean two sources for one piece of state.

Popover.Anchor

Renders a <div>. Optional, and only needed when the thing the panel should point at is not the thing that opens it.

Position it against a row while the button lives in that row's corner:

<Popover.Root>
  <Popover.Anchor>
    <span>Row label</span>
    <Popover.Trigger>Edit</Popover.Trigger>
  </Popover.Anchor>
  <Popover.Content>…</Popover.Content>
</Popover.Root>

Popover.Content

Renders <div popover data-bedrock-popover>.

prop type notes
side Side 'top' | 'right' | 'bottom' | 'left'. Default bottom.
align Align 'start' | 'center' | 'end'. Default center.
sideOffset number Pixels between anchor and panel.
avoidCollisions boolean Default true. Becomes position-try-fallbacks.
id Not forwarded. The trigger's commandfor points at it.
asChild boolean Supported.

side and align compile to position-area plus self-alignment, so the panel follows its anchor on scroll and resize with no listener, no measuring and no re-render. avoidCollisions becomes position-try-fallbacks, which flips it when it would overflow the viewport.

Where a browser has no anchor positioning the panel still opens and still dismisses. It lands where the UA puts a popover, centred, rather than beside the trigger. See browser support for what degrades and how.

Children mount only while it is open, on beforetoggle. Closing discards whatever the panel was holding, which is what makes the uncontrolled root uncontrolled: a half-filled filter form resets because it is gone, not because something reset it.

Popover.Close

Renders <button type="button" commandfor command="hide-popover">, and takes asChild under the same button rule as Trigger.

usePopoverTrigger()

The escape hatch. Returns { commandfor, command } to spread onto an element bedrock will not render for you. Must be called inside a Popover.Root. No validation and no accessibility guarantees. That is the deal, and it is why it has a name you have to type.

Keyboard

key behaviour
Enter/Space On the trigger, toggles. Native button activation.
Escape Closes an auto or hint popover. Platform light dismiss.
Tab Moves through the panel and then out of it. Not trapped.

Focus is not moved into the panel on open, and not restored on close. A popover is not modal; moving focus would be wrong for the common case of a panel that sits beside the control that opened it.

What is not here