Popover
Click-triggered floating panel anchored to a trigger element. Non-modal
— clicks outside dismiss but background remains interactive. Per the
answered design question: reuses runtime/position.ts rather than
adding @floating-ui/dom as a dependency.
When to use
- Quick action menu attached to a button.
- Form helper that doesn't fit inline (richer than HelperText).
- Inline editor anchored to a value.
For modal "full focus" dialogs, use Dialog. For ephemeral hint text on hover, use Tooltip.
Anatomy
<button type="button" class="button -secondary"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="pop-1">
More
</button>
<div class="popover" id="pop-1" role="dialog" aria-labelledby="pop-1-title">
<header class="popover__header">
<h3 id="pop-1-title" class="popover__title">Quick actions</h3>
</header>
<div class="popover__body">
<button class="button -ghost">Duplicate</button>
<button class="button -ghost">Archive</button>
<button class="button -ghost -danger">Delete</button>
</div>
</div>
The runtime (initPopover, auto-registered for triggers with
aria-haspopup="dialog" + aria-controls) handles:
- Click to toggle.
- Position via
runtime/position.ts(auto-flip if doesn't fit). - Click-outside / Escape closes.
- Reposition on scroll and resize.
- Sync
aria-expandedon the trigger.
The popover element starts hidden; the runtime toggles hidden,
data-state="open", and inline top/left styles. Authors do NOT
position the popover manually.
Placement
Default placement is bottom (below the trigger). Override per
instance via data-popover-placement on the trigger:
<button aria-haspopup="dialog" aria-controls="p1" data-popover-placement="right">…</button>
Values: top, right, bottom, left. The positioner flips to the
opposite edge if the preferred placement does not fit in the viewport.
Tokens used
--color-surface-float— background--color-ink-strong,--color-ink-regular--color-border-default,--color-border-muted--shadow-float— elevation--radius-tooltip— corner radius--space-inset-block-{s,m},--space-inset-element-s--space-gap-elements-s--border-width-hairline--font-heading-subsection-*,--font-body-support-*--motion-fast,--motion-easing-default
Component-tier (defined inline)
--popover-max-width— overridable max inline-size
Accessibility
- The trigger carries
aria-haspopup="dialog"andaria-controlspointing at the popover id. The runtime togglesaria-expanded. - The popover element has
role="dialog"(runtime adds it if missing) and should reference its label viaaria-labelledby. - The popover is non-modal — focus is not trapped. Users can Tab out of the popover; that's intentional. For modal cases use Dialog.
- Escape closes the popover and returns focus to the trigger.
- Background is not
inert. Background interactions remain available.
Edge cases
- Off-screen anchor: the position helper clamps to the viewport. An anchor scrolled out of view still gets a positioned popover at the nearest edge.
- Resize / scroll: the runtime listens to
resizeandscroll(capture phase) and re-positions while open. - Nested popovers: allowed, but discouraged. Outside-click detection compares against the closest popover only — an outer popover stays open when clicking the inner one.
Do
- Use real
<button>triggers — keyboard activation comes for free. - Set
aria-haspopup="dialog",aria-controls, and let the runtime managearia-expanded. - Add
aria-labelledbyreferencing the popover's title.
Don't
- Don't position the popover manually with CSS. The runtime sets
top/left. - Don't use Popover for ephemeral hint text. Tooltip is the right primitive there.
- Don't trap focus inside the popover. If you need modal focus trapping, you actually want a Dialog.