Popover

.popover[popover][data-ui="popover"]

Popover — the base overlay surface, tethered to whatever opened it.

Examples

A panel tethered to the button that opened it

View “A panel tethered to the button that opened it” as

Share this page

<button class="btn btn-outline" type="button"
        command="toggle-popover" commandfor="demo-popover-share">Share</button>

<div class="popover" id="demo-popover-share" popover data-ui="popover">
  <p class="popover-title">Share this page</p>
  <div class="popover-body">
    <div class="field">
      <label class="field-label" for="demo-popover-link">Link</label>
      <input class="input input-sm" id="demo-popover-link" type="text"
             value="https://mostlycss.dev" readonly />
    </div>
  </div>
  <footer class="popover-footer">
    <button class="btn btn-primary btn-sm" type="button"
            command="hide-popover" commandfor="demo-popover-share">Copy</button>
  </footer>
</div>

Open it and scroll the page: the panel follows the button. That is anchor-name and position-try-fallbacks, not a scroll listener — it is the part that used to mean shipping Popper or Floating UI.

The top layer, light dismiss and Escape are the browser’s as well, and none of it is scripted. One thing is not free, despite what you may have read: measured 2026-08-08 on all three engines, in the DOM and in the accessibility tree, an invoker using command="toggle-popover" or popovertarget gets no aria-expanded at all, open or closed — so a screen reader is never told this button reveals a panel. That is why every panel here carries data-ui="popover": it wires applyCommandAria(), which reads the state from the popover’s own toggle event and so stays right through light dismiss and Escape. The component opens without script; it is not accessible without script, and we would rather say so than let the demo imply otherwise.

The four placements

View “The four placements” as
Opens above its anchor.
Centred on its anchor.
To the inline end.
No padding of its own.
<button class="btn btn-outline btn-sm" type="button"
        command="toggle-popover" commandfor="demo-popover-above">Above</button>
<div class="popover popover-block-start popover-sm" id="demo-popover-above" popover data-ui="popover">
  <div class="popover-body">Opens above its anchor.</div>
</div>

<button class="btn btn-outline btn-sm" type="button"
        command="toggle-popover" commandfor="demo-popover-below">Below, centred</button>
<div class="popover popover-align-center popover-sm" id="demo-popover-below" popover data-ui="popover">
  <div class="popover-body">Centred on its anchor.</div>
</div>

<button class="btn btn-outline btn-sm" type="button"
        command="toggle-popover" commandfor="demo-popover-side">Inline end</button>
<div class="popover popover-inline-end popover-sm" id="demo-popover-side" popover data-ui="popover">
  <div class="popover-body">To the inline end.</div>
</div>

<button class="btn btn-outline btn-sm" type="button"
        command="toggle-popover" commandfor="demo-popover-plain">Plain</button>
<div class="popover popover-plain popover-sm" id="demo-popover-plain" popover data-ui="popover">
  <div class="popover-body">No padding of its own.</div>
</div>

The flip when a panel would overflow is instant, and it cannot be a transition — measured in Chromium on 2026-08-09 with a one-second transition: inset on a panel forced into flip-block, the panel was at its final position in the first frame.

That is structural rather than a gap: a position-try fallback is resolved at used-value time, after style computation, so there are no two computed values to interpolate. What is animatable, measured in the same session, is an anchor() in an ordinary inset-* declaration when the anchor itself changes — which is how segmented and tabs slide their indicators — but a fallback is not a different anchor, it is a different position against the same one. The placement classes are logical and set one custom property between them, so they mirror in RTL for nothing and anything they do not cover is a --popover-area of your own. Do not add aria-haspopup to these buttons: the panel is a plain stack of controls, and aria-haspopup="menu" on a thing that is not a menu is a lie the screen reader repeats.

Tokens 7

Level 2, declared on .popover itself. Set any of them on that selector to restyle this component without touching the skin.

Level 2 tokens declared by popover
TokenDefault
--popover-durationvar(--duration-fast)
--popover-bgvar(--color-surface-raised)
--popover-border-colorvar(--color-border)
--popover-border-widthvar(--border-width)
--popover-colorvar(--color-on-surface)
--popover-radiusvar(--radius-box)
--popover-shadowvar(--elevation-overlay)

Variants and states

Variants

  • .popover-block-start
  • .popover-align-center
  • .popover-align-end
  • .popover-block-end
  • .popover-inline-end
  • .popover-inline-start
  • .popover-lg
  • .popover-plain
  • .popover-sm

Inside it

  • .popover-body
  • .popover-footer
  • .popover-match
  • .popover-title

State it reads

Read from the platform, never mirrored into a class that could disagree with it.

  • :popover-open
  • data-legacy

Before you ship it

This one needs a script. Opt the markup in with data-ui="popover" and import import 'mostlycss/js/components/popover' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.

What you have to do 1 requirements

  1. ADD data-ui="popover". This component opens without script; it is not accessible without script. It is widely written that an invoker gets aria-expanded for free. Measured 2026-08-08 in all three engines, reading the DOM attribute and the accessibility tree, with command="toggle-popover" and with the older popovertarget: chromium closed: null open: null a11y: button "Open" → button "Open" firefox closed: null open: null a11y: button "Open" → button "Open" webkit closed: null open: null a11y: button "Open" → button "Open" No expanded state anywhere, open or closed. A screen reader user is never told the button reveals a panel, which is a WCAG 4.1.2 failure rather than a plainer experience — so the CSS alone is not a complete component, and saying otherwise would be the comfortable answer rather than the true one. One attribute fixes it: <div class="popover" id="share" popover data-ui="popover">…</div> That wires applyCommandAria() from src/js/core/polyfills.ts, which reads the state from the popover's own toggle event — so light dismiss and Escape are covered, which a click handler on the button would miss. The helper is exported for markup that opts into no data-ui at all. A role="menu" popover needs no data-ui="popover": data-ui="menu" applies the same helper, having verified the role first. See menu.css. Everything else on this page is genuinely scriptless, and stays that way. Do not add aria-haspopup unless the panel really carries the role it promises: aria-haspopup="menu" on a button whose popover is a plain stack of controls is a lie the screen reader repeats. See menu.css for the case where it is correct.

src/css/components/popover.css · npx mostlycss add popover