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&#39;s bottom border extends to the popover&#39;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&#39;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 the popover HTML 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 toggle event. 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-haspopup on 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

TokenDefaultDescription
--popover-bgvar(--color-surface-float)Background color for the popover surface
--popover-colorvar(--color-text)Text color inside the popover
--popover-bordervar(--border-width-default) solid var(--color-border)Border for the popover surface
--popover-radiusvar(--radius-md)Border radius for the popover
--popover-padding-xvar(--ui-pad-x-snug)Horizontal padding inside the popover
--popover-padding-yvar(--ui-pad-y-snug)Vertical padding inside the popover
--popover-shadowvar(--shadow-lg)Box shadow for the popover
--popover-max-width20remMaximum width of the popover
--popover-offsetvar(--space-3xs)Gap between the trigger and the popover edge

Slots

SlotDescription
> header:first-childHeader 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

ClassDescription
.popovercomponent root
.is-topAnchored above the trigger
.is-startAnchored to the inline-start side of the trigger
.is-endAnchored to the inline-end side of the trigger
.is-smNarrow popover at 14rem max width
.is-lgWide popover at 32rem max width
.is-wideUncapped width
.no-borderRemoves the border