Popover
.pop is what a tooltip becomes when it needs a
heading, a paragraph and a button. It is a native popover — light dismiss, Escape and
the top layer from the platform — anchored to its trigger by CSS, and unlike a tooltip
the reader can move into it and click things.
When to use it
Use it for an explanation the reader might act on: what a field means and a link to the documentation, why a build failed and a button to retry, a definition with a "don't show again" control. The test is whether there is anything to interact with — if there is, a tooltip cannot hold it.
Missing environment variable
The build could not find DATABASE_URL. Add it in Settings and run the build again.
<button class="btn" popovertarget="dx-pop">Why did this build fail?</button>
<div class="pop" popover id="dx-pop" aria-labelledby="dx-pop-t">
<p class="pop-title" id="dx-pop-t">Missing environment variable</p>
<p class="pop-body">The build could not find <code>DATABASE_URL</code>. Add it in Settings and run the build again.</p>
<div class="pop-actions">
<button class="btn btn-sm btn-primary">Open settings</button>
<button class="btn btn-sm" popovertarget="dx-pop" popovertargetaction="hide">Dismiss</button>
</div>
</div>
<button class="btn" popovertarget="dx-pop">Why did this build fail?</button>
<div class="pop" popover id="dx-pop" aria-labelledby="dx-pop-t">
<p class="pop-title" id="dx-pop-t">Missing environment variable</p>
<p class="pop-body">The build could not find <code>DATABASE_URL</code>. Add it in Settings and run the build again.</p>
<div class="pop-actions">
<button class="btn btn-sm btn-primary">Open settings</button>
<button class="btn btn-sm" popovertarget="dx-pop" popovertargetaction="hide">Dismiss</button>
</div>
</div>
popovertargetaction="hide" on a button inside the panel closes it. That
is the declarative close, and it means a popover with a dismiss button needs no script
at all.
.anchor and the naming problem
CSS anchor positioning connects two elements by name: the trigger declares
anchor-name, the panel declares position-anchor, and the
names must match. Every pair on a page needs a unique one.
.anchor is the one-line class that does the first half:
.anchor { anchor-name: var(--anchor); }
So the pairing is --anchor set to the same value on both elements. You
can write it yourself:
<button class="btn anchor" style="--anchor:--pop-build" popovertarget="p1">Why?</button>
<div class="pop" popover id="p1" style="--anchor:--pop-build">…</div>
Or let deck.js do it. It walks every [popovertarget],
generates a name, adds .anchor to the trigger and sets
--anchor on both — skipping any panel that already has one, so a
hand-written name always wins. That is why the example above needs neither the class
nor the property.
Generating the name server-side is the other good option, and the source comment
recommends it for Keel: style="--anchor:--pop-<?= $id ?>". It
removes the JavaScript from the path entirely and guarantees uniqueness from data you
already have.
Positioning
position-area: block-end;
position-try-fallbacks: flip-block, flip-inline, block-start span-inline-start;
justify-self: anchor-center;
Below the trigger by preference, centred on it. If there is no room below it flips above; if it would overflow an edge it flips inline; and the last fallback pins it above and toward the starting edge, which is the option that fits in the tightest corner.
Width is min(20rem, calc(100vw - 1.5rem)) — capped for
readability, and never wider than the viewport, so it does not need a mobile variant.
All of this is inside @supports (anchor-name: --a). Where the browser
lacks anchor positioning the panel still opens as a popover in the top layer; it is
simply centred by the browser's default popover placement instead of being attached to
its trigger. That degrades to something usable rather than to something broken.
Title, body and actions
Three parts, all optional, all plain: .pop-title is a weight change,
.pop-body is smaller muted text, and .pop-actions is a flex
row with a gap and a top margin. There is no header border and no footer band — a
popover is small enough that regions would be more structure than content.
Labor code
The three-digit code a technician enters against a repair. It sets the hours billed.
<button class="btn btn-sm" popovertarget="dx-pop-min">What is a labor code?</button>
<div class="pop" popover id="dx-pop-min" aria-labelledby="dx-pop-min-t">
<p class="pop-title" id="dx-pop-min-t">Labor code</p>
<p class="pop-body">The three-digit code a technician enters against a repair. It sets the hours billed.</p>
</div>
<button class="btn btn-sm" popovertarget="dx-pop-min">What is a labor code?</button>
<div class="pop" popover id="dx-pop-min" aria-labelledby="dx-pop-min-t">
<p class="pop-title" id="dx-pop-min-t">Labor code</p>
<p class="pop-body">The three-digit code a technician enters against a repair. It sets the hours billed.</p>
</div>
Classes
These live in src/25-anchor.css and are not a component root — the
extractor finds no .pop selector outside the
@supports block to define it from — so there is no completeness check on
this page.
| Class | Layer | What it does | Source |
|---|---|---|---|
.pop |
deck.components |
A richer tooltip: a heading, body copy, and actions. Used for the "what does this labor code mean" pattern. | 25-anchor.css:115 |
.pop-actions |
deck.components |
The action row at the bottom of a popover card. A button here can close the panel with popovertargetaction="hide" and no script. | 25-anchor.css:145 |
.pop-body |
deck.components |
The explanatory text inside a popover card. | 25-anchor.css:142 |
.pop-title |
deck.components |
The heading inside a popover card. | 25-anchor.css:140 |
.anchor |
deck.components |
Put .anchor on the trigger and --anchor on both trigger and panel. Each pair needs a unique name, so generate it server-side in Keel: <button class="anchor" style="--anchor:--menu-<?= $id ?>"> | 25-anchor.css:24 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--text-sm |
.8125rem |
|
--r-md |
12px |
|
--shadow-4 |
0 8px 16px oklch(20% .03 var(--shadow-color) / .08), 0 24px 56px oklch(20% .03 var(--shadow-color) / .14) |
|
--space-1 |
.25rem |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-4 |
1rem |
|
--space-6 |
1.5rem |
|
--dur-2 |
190ms |
Accessibility
-
The platform supplies the behaviour. Light dismiss, Escape,
closing when another popover opens, and
aria-expandedon the trigger are the browser's. The panel is in the top layer, so it escapes clipping ancestors. - Focus is not moved into it. A popover does not trap or move focus. For a panel with actions in it, that means a keyboard user opens it and then has to tab forward to reach the buttons — which works, because the panel follows the trigger in the DOM, but only if you put it there. Keep the panel immediately after its trigger in the markup.
-
Name it.
aria-labelledbypointing at.pop-title, as every example here does. -
Do not give it
role="tooltip". A tooltip role tells a screen reader the content is a label and not interactive. Anything with a button in it is a dialog-like panel; leave the implicit grouping, or userole="dialog"if it genuinely blocks. - It works on touch, unlike the CSS tooltip, because it opens on click rather than hover. That alone is often the reason to choose it.
- Light dismiss can be surprising. Any outside click closes the panel, including a click on a form field the reader meant to fill in first. For a panel holding a small form, a modal is the safer shape.
-
Contrast. Unlike a tooltip, a popover is
--surfaceand--text— an ordinary page surface, so it follows the theme and inherits the page's contrast guarantees.
Right to left
position-area, justify-self: anchor-center and the fallback
keywords are all logical, and the panel's padding is symmetrical. The
block-start span-inline-start fallback resolves to the correct corner in
either direction without a second rule.
متغير بيئة مفقود
لم يتم العثور على DATABASE_URL.
<div dir="rtl">
<button class="btn" popovertarget="dx-pop-rtl">لماذا فشل البناء؟</button>
<div class="pop" popover id="dx-pop-rtl" aria-labelledby="dx-pop-rtl-t">
<p class="pop-title" id="dx-pop-rtl-t">متغير بيئة مفقود</p>
<p class="pop-body">لم يتم العثور على <code>DATABASE_URL</code>.</p>
<div class="pop-actions">
<button class="btn btn-sm btn-primary">الإعدادات</button>
</div>
</div>
</div>
<div dir="rtl">
<button class="btn" popovertarget="dx-pop-rtl">لماذا فشل البناء؟</button>
<div class="pop" popover id="dx-pop-rtl" aria-labelledby="dx-pop-rtl-t">
<p class="pop-title" id="dx-pop-rtl-t">متغير بيئة مفقود</p>
<p class="pop-body">لم يتم العثور على <code>DATABASE_URL</code>.</p>
<div class="pop-actions">
<button class="btn btn-sm btn-primary">الإعدادات</button>
</div>
</div>
</div>
Reduced motion
The panel fades and rises four pixels over
190ms, with
overlay and display in the transition so it stays rendered
while it fades out. The global reset collapses it to .01ms under
prefers-reduced-motion: reduce.
Printing
.pop has no print rule and is not in the never-print list, so an open
popover prints where it falls — over whatever it is anchored above. That is unlikely
to come up, since printing with a popover open is rare, but it is worth knowing that
Deck does not handle it.
If an explanation matters on paper it belongs in the page, not in a panel that only exists while a reader is holding it open.
Overriding it
/* One popover, no layer needed */
<div class="pop" popover style="--anchor:--pop-x;inline-size:min(28rem, 92vw)">
@layer app.components {
/* Prefer opening to the inline end rather than below */
.pop-side {
position-area: inline-end;
position-try-fallbacks: flip-inline, block-end;
align-self: anchor-center;
justify-self: auto;
}
}
If you change the axis, change the centring with it:
justify-self: anchor-center centres across the inline axis and
align-self does the block axis. Leaving both set fights itself.
When not to use it
-
Not for a label. A few words naming a control is
.tooltipor.tip. A popover is a card, and a card for two words is heavy. -
Not for a decision the reader must make. Light dismiss means an
outside click discards it silently. Use
.modal, which blocks and has to be answered. - Not for a form of any size. A stray click loses the input. A popover is for reading with an optional action, not for entering data.
-
Not for a list of actions. Use
.menu, which is built for rows of items and sizes itself to its trigger. -
Not on hover. A popover opens on click. Wiring it to
pointerentergives you a panel that appears when the pointer passes over the trigger on the way somewhere else, and light dismiss will fight the reader trying to move into it.