Popover
Overlay surface
Intent
Popover is a small floating surface that appears next to a
trigger -- a hover card with profile info, a help bubble
explaining a field, a quick filter panel, a keyboard
shortcuts cheat sheet. It builds on the native Popover API
(popover attribute, top-layer rendering, light
dismiss) and CSS anchor positioning, so the markup stays
declarative: a button that points at a panel, no open/close
state to manage in your application.
Reach for dialog when the overlay should interrupt the user -- modal confirmations, focused tasks. Reach for dropdown when the overlay is a menu of actions or selections, where arrow keys, focus-on-open, and ARIA menu semantics matter. Use popover for everything else: presentational floating content where the consumer arranges what's inside.
Basic usage
A popover needs two pieces: a panel with the
popover attribute and a stable
id, plus a trigger that invokes it via
commandfor="id" and
command="toggle-popover". brio uses
commandfor + command across every
overlay (dialog, popover, dropdown) for one consistent
invoker pattern. Click the button to toggle; click outside
or press Escape to dismiss.
The native HTML popovertarget attribute also
invokes popovers and is shorter to type. Pick
one per trigger -- not both. Using
popovertarget alongside commandfor
on the same button fires two invocations per click; the
race produces stuck-open or stuck-state popovers across
browsers. brio's docs use commandfor
consistently because dialog requires it
(show-modal has no
popovertarget equivalent), and matching keeps
the mental model uniform.
A floating surface anchored to its trigger. Click outside or press Escape to close.
<docs-resize>
<div>
<button class="button" type="button" commandfor="pop-basic" command="toggle-popover">Open popover</button>
<div class="popover" id="pop-basic" popover="">
<p>A floating surface anchored to its trigger. Click
outside or press Escape to close.</p>
</div>
</div>
</docs-resize>Slots
One structural slot, keyed by element. The popover's body
is whatever you place inside it -- a paragraph, a list, a
small form, a composition primitive like
.stack or .cluster. There's
intentionally no <footer> counterpart
to dialog's three-slot layout: popovers are
content-focused, and the brio principle of composing
layout with primitives covers the rest.
Header (<header>)
A
<header>
placed as the first child of the popover becomes a flex
row with a bottom border that bleeds edge-to-edge through
the popover's padding. Holds a heading; optionally a
dismiss button. Pick a heading level that fits the
document hierarchy -- popover normalizes the visual size
so h2 through h6 render the
same.
A <header> at any other position in
the popover stays at UA defaults; the recipe is keyed to
first-child only.
Quick tips
The header's bottom border extends to the popover's edges by pulling through the surface padding.
<docs-resize>
<div>
<button class="button" type="button" commandfor="pop-header" command="toggle-popover">Help</button>
<div class="popover" id="pop-header" popover="">
<header>
<h3>Quick tips</h3>
<button class="dismiss" type="button" commandfor="pop-header" command="hide-popover" aria-label="Close">×</button>
</header>
<p>The header's bottom border extends to the popover's
edges by pulling through the surface padding.</p>
</div>
</div>
</docs-resize>Variants
Size
Size variants tune the popover to its content.
.is-sm for compact bubbles -- short hints,
single-line confirmations. The default for typical content.
.is-lg for longer-form floating content --
mini-forms, multi-paragraph callouts. .is-wide
removes the max-width entirely, useful for mega-menu-style
content where the panel spans much of the viewport.
Compact bubble for short hints.
The default size accommodates a paragraph or two of explanatory content without feeling cramped.
A larger surface for content that needs more room -- a small form, a longer explanation, or a list of grouped options that wouldn't fit in the default size.
No max-width. Useful when the surface is meant to span much of the viewport, mega-menu style. Pair with composition primitives for internal layout.
<docs-resize>
<div class="cluster is-tight">
<button class="button is-outlined" type="button" commandfor="pop-sm" command="toggle-popover">Small</button>
<button class="button is-outlined" type="button" commandfor="pop-default" command="toggle-popover">Default</button>
<button class="button is-outlined" type="button" commandfor="pop-lg" command="toggle-popover">Large</button>
<button class="button is-outlined" type="button" commandfor="pop-wide" command="toggle-popover">Wide</button>
<div class="popover is-sm" id="pop-sm" popover="">
<p>Compact bubble for short hints.</p>
</div>
<div class="popover" id="pop-default" popover="">
<p>The default size accommodates a paragraph or two of
explanatory content without feeling cramped.</p>
</div>
<div class="popover is-lg" id="pop-lg" popover="">
<p>A larger surface for content that needs more room --
a small form, a longer explanation, or a list of
grouped options that wouldn't fit in the default
size.</p>
</div>
<div class="popover is-wide" id="pop-wide" popover="">
<p>No max-width. Useful when the surface is meant to
span much of the viewport, mega-menu style. Pair
with composition primitives for internal layout.</p>
</div>
</div>
</docs-resize>Placement
Default placement is below the trigger. .is-top,
.is-start, and .is-end place the
popover above, to the inline-start, or to the inline-end
side respectively. The browser flips the placement
automatically when the chosen side would overflow the
viewport (anchor positioning's
position-try-fallbacks: flip-block, flip-inline),
so the variant expresses a preference, not a guarantee.
Resize the demo's container to see the flip behavior.
Default placement: below the trigger.
Above the trigger.
To the inline-start side of the trigger.
To the inline-end side of the trigger.
<docs-resize>
<div class="cluster is-tight">
<button class="button is-outlined" type="button" commandfor="pop-bottom" command="toggle-popover">Below</button>
<button class="button is-outlined" type="button" commandfor="pop-top" command="toggle-popover">Above</button>
<button class="button is-outlined" type="button" commandfor="pop-start" command="toggle-popover">Start</button>
<button class="button is-outlined" type="button" commandfor="pop-end" command="toggle-popover">End</button>
<div class="popover" id="pop-bottom" popover="">
<p>Default placement: below the trigger.</p>
</div>
<div class="popover is-top" id="pop-top" popover="">
<p>Above the trigger.</p>
</div>
<div class="popover is-start" id="pop-start" popover="">
<p>To the inline-start side of the trigger.</p>
</div>
<div class="popover is-end" id="pop-end" popover="">
<p>To the inline-end side of the trigger.</p>
</div>
</div>
</docs-resize>State and interaction
The native Popover API drives all open/close state. Click outside, press Escape, or hit a closing invoker -- no JavaScript in the consumer application:
- Toggle:
<button commandfor="id" command="toggle-popover">opens or closes the popover from a single trigger. - Open only:
command="show-popover"opens but does not close on a second click. Useful when an explicit close action lives elsewhere. - Close:
command="hide-popover"closes from any invoker on the page -- inside the popover (a dismiss button) or outside (a "cancel" elsewhere). - Light dismiss: built into the Popover API. A click outside the popover surface, or pressing Escape, closes it.
Open state in CSS is the :popover-open
pseudo-class. The popover renders in the browser's top
layer when open, so it always sits above page content
without z-index gymnastics.
Positioning
Popover uses CSS anchor positioning. The trigger that
invokes a popover (the element with commandfor
pointing at the popover's id) becomes the panel's implicit
anchor automatically -- no anchor-name
declaration on the trigger is required. The browser places
the panel relative to that anchor and flips it when the
chosen placement would overflow the viewport.
Browsers without anchor positioning still render the popover via the Popover API -- the panel appears at the browser's default top-layer position rather than next to the trigger. Functional, just not anchored. brio doesn't bundle a polyfill: the modern baseline of major engines supports anchor positioning, and the degraded experience on older browsers stays usable.
Animation
Entry uses @starting-style to declare the
starting transform; exit uses
:not(:popover-open) as the transition target.
Both transitions include overlay and
display with allow-discrete so
the panel holds its visible state through the exit
animation rather than snapping away.
Opacity transitions are fenced behind a non-Safari
@supports block. Safari has a long-running
pattern of quirks rendering opacity on top-layer
popovers; the fence avoids the visible jank. Other
engines get the full fade; Safari gets a transform-only
transition. Both feel responsive; only the polish differs.
Reference
.popover-- the floating surface. Apply to any element with thepopoverHTML attribute.<header>:first-child-- title row with a full-bleed bordered separator. Slot keyed by element, not class..is-sm,.is-lg,.is-wide-- size variants..is-top,.is-start,.is-end-- placement variants. Default is below the trigger..no-border-- strip the surface border.
Not classes: popover is a native HTML
attribute on the panel; commandfor and
command are the native invoker attributes
on the trigger.
Customization
Popover exposes scoped tokens for color (background,
text), sizing (max-width, horizontal and vertical
padding, radius), border, shadow, and the offset between
panel and trigger. Size variants remap only
--popover-max-width; placement variants
remap position-area and the offset margin.
The header's bordered separator is full-bleed (it
extends through the popover's horizontal padding via
negative margins keyed to
--popover-padding-x) -- changing the
surface padding moves the separator's edges with it.
Full token list and defaults:
src/css/popover.css.
Accessibility
-
Focus is the consumer's responsibility.
Generic popovers cover too many shapes -- hover cards,
form inputs, help bubbles, mega-menus -- for a single
focus policy to be right. The Popover API moves focus
with the trigger; if the popover holds interactive
content the consumer wants opened with focus inside,
wire it via the panel's
toggleevent. For action menus with arrow-key navigation and focus-on-open, reach for dropdown. -
Trigger ARIA when popover is interactive.
If the popover contains buttons, links, or form
inputs, add
aria-haspopupon the trigger (the value depends on the panel's role; for generic content,"dialog"is reasonable). brio doesn't auto-set this for generic popovers because the right value is content-dependent --<brio-dropdown>handles the menu case automatically. - Don't use popover for critical content. Anything the user must read or act on belongs in a dialog. Popovers are dismissible by accident -- a stray click outside, an Escape press from elsewhere on the page. Save them for supplemental, non-blocking content.
-
macOS keyboard quirk: when a popover
contains
<a>links (a megamenu, a link grid, a help-bubble with reference URLs), Tab on macOS skips them by default -- the OS only includes form controls in Tab order unless the user has enabled full keyboard navigation in their accessibility settings. Windows and Linux include links in Tab order natively. This is OS behavior, not a brio limitation; the links are focusable in every browser. For navigation that needs guaranteed keyboard reach independent of OS settings, use dropdown -- its custom element wires arrow-key navigation that doesn't depend on Tab.
CSS Reference: Popover
Floating content surface anchored to a trigger
Source: src/css/popover.css
Tokens
| Token | Default | Description |
|---|---|---|
--popover-bg | var(--color-surface-float) | Background color for the popover surface |
--popover-color | var(--color-text) | Text color inside the popover |
--popover-border | var(--border-width-default) solid var(--color-border) | Border for the popover surface |
--popover-radius | var(--radius-md) | Border radius for the popover |
--popover-padding-x | var(--ui-pad-x-snug) | Horizontal padding inside the popover |
--popover-padding-y | var(--ui-pad-y-snug) | Vertical padding inside the popover |
--popover-shadow | var(--shadow-lg) | Box shadow for the popover |
--popover-max-width | 20rem | Maximum width of the popover |
--popover-offset | var(--space-3xs) | Gap between the trigger and the popover edge |
Slots
| Slot | Description |
|---|---|
> header:first-child | Header slot -- full-bleed bordered separator (P2 test 2: extends through parent's --popover-padding-x via negative inline margins). Keyed to first-child so a <header> dropped mid-popover stays at UA defaults instead of breaking layout. |
Classes
| Class | Description |
|---|---|
.popover | component root |
.is-top | Anchored above the trigger |
.is-start | Anchored to the inline-start side of the trigger |
.is-end | Anchored to the inline-end side of the trigger |
.is-sm | Narrow popover at 14rem max width |
.is-lg | Wide popover at 32rem max width |
.is-wide | Uncapped width |
.no-border | Removes the border |