abaabil

Popover

5th lightest of the thirteen, by js weight

The native Popover API, wrapped. The trigger, the top layer, light-dismiss and Escape all come from the browser, so every tier ships zero JavaScript and every tier renders on the server.

Example (a11y tier)

The button below works before this page's JavaScript has loaded, and would work with JavaScript disabled entirely. Nothing in the library runs to open it.

Anything can go in here: a form, a menu, a description. The page behind stays live and interactive, which is the difference between a popover and a dialog.

Import

import Popover from 'abaabil/popover'         // structure only
import Popover from 'abaabil/popover/styled'  // + CSS
import Popover from 'abaabil/popover/a11y'    // + accessible name for the panel

The whole component is an attribute

What ships is essentially this:

<button popovertarget="menu">Options</button>
<div id="menu" popover>...</div>

The browser does the rest: opening and closing, rendering above the rest of the page in the top layer, dismissing on a click outside, closing on Escape, and exposing the button-to-panel relationship to assistive technology. This is the clearest case on the site of the argument the rest of the library is making, which is that a great deal of what component libraries carry is JavaScript reimplementing things the browser now does.

Why id is required

Generating the id would mean useId, which would mean a client boundary, which would cost this component the single property that makes it worth using. An explicit id is a small thing to ask in exchange. The library's build gate pins all three popover tiers as server-only, so this cannot be undone by accident later.

What it needs, and what happens without it

The Popover API is Chrome and Edge 114, Firefox 125, Safari 17. That sits on this library's existing floor, so it raises nothing in practice, but it is a hard requirement rather than an enhancement. Below it the popover attribute is inert and the panel renders as an ordinary always-visible block under its trigger. The content stays reachable, which is why it is left visible rather than hidden.

Where the panel appears is a separate question from whether it works. Attaching it to its trigger needs CSS anchor positioning, which is not yet Baseline, so it is applied behind @supports. Without it the popover falls back to the browser default, centred in the viewport. Placing it next to the trigger any other way would mean measuring the DOM in JavaScript.

Popover or dialog?

A dialog is modal: the page behind it goes inert, and the user has to deal with it. A popover is not: the page stays live, and clicking anywhere else dismisses it. If the user can reasonably ignore it, it is a popover.

Props

PropTypeDefaultTierDescription
idstring— (required)allPanel id; also wires the trigger.
triggerReactNodeallButton content.
triggerPropsobjectallSpread onto the button.
mode'auto' | 'manual''auto'allauto light-dismisses and closes on Escape; manual does neither.
labelstringa11yAccessible name for the panel, as aria-label.
labelledBystringa11yId of an element naming the panel. Use instead of label when the panel renders a visible heading, so the name is not said twice.

PopoverTrigger and PopoverPanel are exported from every tier too, for placing the two apart.

Deliberately absent: aria-expanded on the trigger. With no JavaScript it could only be a static value, and a static aria-expanded is worse than none, because it states a fact that stops being true the moment the popover opens. Browsers expose the popovertarget relationship natively.