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.

Click outside to dismiss, or use the button — which needs no JavaScript either

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.

Title and body, no actions

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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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-expanded on 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-labelledby pointing 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 use role="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 --surface and --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 .tooltip or .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 pointerenter gives 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.