Component
popover
Demo
tokens.css,
base.css and popover.css. No scripts.
Source
Copy both files, or run npx nojsui add popover.
Browser support
Shipped in every major engine, but only recently — older versions need the fallback.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Popover | newly | 116 | 116 | 125 | 17 | 116 | 125 | 18.3 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| @starting-style | newly | 117 | 117 | 129 | 17.5 | 117 | 129 | 17.5 |
| transition-behavior | newly | 117 | 117 | 129 | 17.4 | 117 | 129 | 17.4 |
| Anchor positioning3 of its parts | newly | 129 | 129 | 147 | 26 | 129 | 147 | 26 |
Usage
A button and a panel. The only wiring is popovertarget pointing at the
panel’s id:
<div class="sk-popover">
<button type="button" class="sk-popover__trigger" popovertarget="my-panel">
Details
</button>
<div id="my-panel" popover class="sk-popover__panel">
…
</div>
</div>
The id must be unique on the page. Nothing else is per-instance — no
anchor-name, no position-anchor, no inline styles.
How it works
The invoker is the anchor. A button with popovertarget becomes the
popover’s implicit anchor element, so position-area has something to
position against without any anchor-name / position-anchor pair. Verified in
Chromium, WebKit and Gecko. This is the whole reason the markup stays
copy-pasteable: a component that needed a unique anchor name per instance would
need a unique stylesheet rule per instance too.
popover="auto" does the hard parts. Light dismiss on outside click, Esc
to close, top-layer painting above everything regardless of z-index or
overflow: hidden ancestors, and returning focus to the trigger on close. None
of that is reimplemented here, which is why there is no script.
Placement flips instead of overflowing. position-area: block-end span-inline-end puts the panel below the trigger, aligned to its inline-start
edge. position-try-fallbacks lists three alternates, and the browser picks the
first that fits — so a trigger near the bottom of the viewport opens upward
without measuring anything.
Exit animation needs allow-discrete. display and overlay are discrete
properties: without transition-behavior: allow-discrete they flip in one step
and the panel is gone before it can fade. The entry animation only needs
@starting-style. Both are inside the reduced-motion guard.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. popover.css only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-popover-panel-bg | --sk-color-surface-raised | Panel background |
--sk-popover-panel-fg | --sk-color-text | Panel text |
--sk-popover-panel-border-color | --sk-color-border | Panel border |
--sk-popover-panel-radius | --sk-radius-lg | Panel corner radius |
--sk-popover-panel-shadow | --sk-shadow-lg | Panel elevation |
--sk-popover-panel-max-inline-size | 22rem | Widest the panel grows |
--sk-popover-trigger-bg | --sk-color-surface | Trigger background |
--sk-popover-trigger-bg-hover | --sk-color-surface-raised | Trigger background on hover |
--sk-popover-trigger-fg | --sk-color-text | Trigger text |
--sk-popover-trigger-border-color | --sk-color-border-strong | Trigger border |
--sk-popover-trigger-radius | --sk-radius-md | Trigger corner radius |
--sk-popover-item-bg-hover | --sk-color-accent-subtle | Menu item background on hover |
--sk-popover-danger-fg | --sk-color-danger | Text of a data-tone="danger" item |
--sk-popover-danger-bg-hover | --sk-color-danger-subtle | Background of a danger item on hover |
:root { --sk-popover-panel-radius: 0; }
.dense-ui { --sk-popover-panel-max-inline-size: 16rem; }
Placement is not themeable. position-area, the try-fallbacks and the
top-layer painting are what make the popover land in the right place and stay
dismissible; they are behaviour, not decoration.
Keyboard contract
| Key | Behaviour |
|---|---|
Tab | Moves to the trigger |
Enter / Space on the trigger | Opens the panel; focus stays on the trigger |
Tab while open | Moves through the panel’s contents in DOM order |
Esc | Closes the panel and returns focus to the trigger |
| Click outside | Closes the panel (light dismiss) |
All of it is native popover behaviour. Nothing here is re-implemented, which
also means nothing here can drift from what the browser does.
Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done.
Accessibility notes
Why there is no role="menu". The ARIA menu pattern is a promise: arrow
keys move between items, Home/End jump to the ends, typeahead selects, and
Tab leaves the whole menu as one stop. Delivering that without JavaScript is
not possible. Applying role="menu" anyway would tell a screen-reader user to
expect all of it and then deliver none of it — strictly worse than having no
role at all, because it converts a working list of buttons into a broken menu.
So the menu variant is what it says it is: a <ul> of <button>s. Tab moves
between them, Enter activates. A screen reader announces “list, 4 items” and
each button by its label, which is accurate.
If you need the real menu pattern with arrow-key roving focus, that component needs JavaScript and does not belong in this kit.
Known limitation: the trigger does not announce expanded state. A screen
reader hears “Actions, button” whether the panel is open or closed. Measured
with computed-ARIA snapshots in Chromium, WebKit and Gecko: none of them expose
an expanded/collapsed state for a popovertarget button.
This cannot be fixed here. aria-expanded would have to be toggled as the
panel opens and closes, and toggling an attribute needs JavaScript. Putting a
static aria-expanded="false" in the markup would be worse than nothing — it
would be a lie exactly half the time.
What to do about it:
- Label the trigger so it makes sense without state. “Actions” and “Details” work; a bare chevron does not.
- If state announcement matters, use a disclosure instead.
<details>/<summary>exposes open/closed natively and needs no script. Reach for this popover when the panel is transient and the trigger’s label already says what it does.
Focus is never moved by this component. Opening leaves focus where it was —
correct for a non-modal popover, since the user chooses when to enter it —
and closing with Esc returns focus to the trigger natively.
One nuance worth knowing: on Safari, clicking a button does not focus it (the
OS “Full Keyboard Access” setting governs that). So a mouse user who opens the
panel and presses Esc will not see focus return to the trigger, because focus
was never on it. Via the keyboard the return works in all three engines —
verified.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
| Popover API | newly | No component. The panel renders inline and is always visible — content is reachable, styling is wrong. This is the floor. |
position-area, position-try-fallbacks, anchor-size | newly | Panel becomes a sheet pinned to the bottom of the viewport. Fully usable; it just is not next to the trigger. |
@starting-style | newly | No entry animation — the panel appears at full opacity. |
transition-behavior: allow-discrete | newly | No exit animation — the panel disappears at once. Entry still animates. |
Note on the sheet fallback: a popover is painted in the top layer, and its containing block is the viewport rather than any positioned ancestor. Placing it below the trigger without anchor positioning is therefore not possible at all — not merely awkward — so the fallback is a deliberate sheet rather than a degraded attempt at anchoring.