Popover
Popover — the base overlay surface, tethered to whatever opened it.
Examples
<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.
<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.
| Token | Default |
|---|---|
| --popover-duration | var(--duration-fast) |
| --popover-bg | var(--color-surface-raised) |
| --popover-border-color | var(--color-border) |
| --popover-border-width | var(--border-width) |
| --popover-color | var(--color-on-surface) |
| --popover-radius | var(--radius-box) |
| --popover-shadow | var(--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-opendata-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
- ADD
data-ui="popover". This component opens without script; it is not accessible without script. It is widely written that an invoker getsaria-expandedfor free. Measured 2026-08-08 in all three engines, reading the DOM attribute and the accessibility tree, withcommand="toggle-popover"and with the olderpopovertarget: 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 wiresapplyCommandAria()fromsrc/js/core/polyfills.ts, which reads the state from the popover's owntoggleevent — 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 nodata-uiat all. Arole="menu"popover needs nodata-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 addaria-haspopupunless 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