Skip to content

Component

popover

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and popover.css. No scripts.

Source

Copy both files, or run npx nojsui add popover.

Source for popover
<!-- .sk-popover — canonical demo markup. This is also what the CLI copies.

     The only wiring is popovertarget -> id. That invoker relationship is what
     makes the button the panel's *implicit anchor*, so there is no anchor-name
     and no position-anchor to keep in sync per instance. -->
<div class="sk-popover">
  <button type="button" class="sk-popover__trigger" popovertarget="sk-popover-info">
    Details
  </button>

  <div id="sk-popover-info" popover class="sk-popover__panel">
    <p class="sk-popover__text">
      Light dismiss, <kbd>Esc</kbd>, and focus return are all native — no script
      on this page.
    </p>
  </div>
</div>

<div class="sk-popover">
  <button type="button" class="sk-popover__trigger" popovertarget="sk-popover-menu">
    Actions
  </button>

  <!-- A menu variant, built from real buttons in a real list. No role="menu":
       see the README for why faking the ARIA menu pattern here would be worse
       than not having it. -->
  <div id="sk-popover-menu" popover class="sk-popover__panel">
    <ul class="sk-popover__menu">
      <li><button type="button" class="sk-popover__item">Duplicate</button></li>
      <li><button type="button" class="sk-popover__item">Rename</button></li>
      <li><button type="button" class="sk-popover__item">Move to…</button></li>
      <li>
        <button type="button" class="sk-popover__item" data-tone="danger">
          Delete
        </button>
      </li>
    </ul>
  </div>
</div>
/* .sk-popover — an anchored popover and dropdown menu, with no JavaScript.
   Everything interactive here is the platform: popover="auto" gives light
   dismiss, Esc, top-layer painting and focus return; popovertarget gives the
   invoker relationship. This file only places and dresses it.

   Specs:
   - popover attribute ...... https://html.spec.whatwg.org/multipage/popover.html
   - implicit anchor ........ https://html.spec.whatwg.org/multipage/popover.html#popover-anchor
   - position-area .......... https://drafts.csswg.org/css-anchor-position-1/#position-area
   - position-try-fallbacks . https://drafts.csswg.org/css-anchor-position-1/#position-try-fallbacks
   - @starting-style ........ https://drafts.csswg.org/css-transitions-2/#defining-before-change-style
   - transition-behavior .... https://drafts.csswg.org/css-transitions-2/#transition-behavior-property */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. The panel is painted in the top layer
   but is still a DOM descendant of this root, so it inherits these. */
.sk-popover {
  --_trigger-bg: var(--sk-popover-trigger-bg, var(--sk-color-surface));
  --_trigger-bg-hover: var(--sk-popover-trigger-bg-hover, var(--sk-color-surface-raised));
  --_trigger-fg: var(--sk-popover-trigger-fg, var(--sk-color-text));
  --_trigger-border: var(--sk-popover-trigger-border-color, var(--sk-color-border-strong));
  --_trigger-radius: var(--sk-popover-trigger-radius, var(--sk-radius-md));
  --_panel-bg: var(--sk-popover-panel-bg, var(--sk-color-surface-raised));
  --_panel-fg: var(--sk-popover-panel-fg, var(--sk-color-text));
  --_panel-border: var(--sk-popover-panel-border-color, var(--sk-color-border));
  --_panel-radius: var(--sk-popover-panel-radius, var(--sk-radius-lg));
  --_panel-shadow: var(--sk-popover-panel-shadow, var(--sk-shadow-lg));
  --_panel-max-inline-size: var(--sk-popover-panel-max-inline-size, 22rem);
  --_item-bg-hover: var(--sk-popover-item-bg-hover, var(--sk-color-accent-subtle));
  --_danger-fg: var(--sk-popover-danger-fg, var(--sk-color-danger));
  --_danger-bg-hover: var(--sk-popover-danger-bg-hover, var(--sk-color-danger-subtle));

  display: inline-block;
}

.sk-popover__trigger {
  border: var(--sk-border-width) solid var(--_trigger-border);
  border-radius: var(--_trigger-radius);
  background: var(--_trigger-bg);
  color: var(--_trigger-fg);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-md);
  font-weight: var(--sk-weight-medium);
  min-block-size: var(--sk-size-tap-target);
}

.sk-popover__trigger:hover {
  background: var(--_trigger-bg-hover);
}

/* ---------------------------------------------------------------------------
   Placement

   Fallback first, enhancement second. Without anchor positioning the panel is
   a sheet pinned to the block-end edge of the viewport.

   That is not "below the trigger": a popover is painted in the top layer, and
   its containing block is the viewport rather than any positioned ancestor —
   verified in Chromium, WebKit and Gecko, where inset-block-start: 100% inside
   a position: relative wrapper resolves against the viewport, not the wrapper.
   So placing it near the trigger is not possible without anchor positioning,
   and a deterministic sheet beats a panel dropped in the middle of the page.
--------------------------------------------------------------------------- */

.sk-popover__panel {
  position: fixed;
  inset-block: auto var(--sk-space-md);
  inset-inline: var(--sk-space-md);
  margin: 0;
  margin-inline: auto;
  inline-size: auto;
  max-inline-size: var(--_panel-max-inline-size);
  padding-block: var(--sk-space-sm);
  padding-inline: var(--sk-space-sm);
  border: var(--sk-border-width) solid var(--_panel-border);
  border-radius: var(--_panel-radius);
  background: var(--_panel-bg);
  color: var(--_panel-fg);
  box-shadow: var(--_panel-shadow);
}

@supports (position-area: block-end) {
  .sk-popover__panel {
    /* The invoker is the implicit anchor, so this needs no anchor-name. */
    position: absolute;
    position-area: block-end span-inline-end;
    inset: auto;
    margin: var(--sk-space-2xs);
    min-inline-size: anchor-size(inline);
    max-inline-size: var(--_panel-max-inline-size);

    /* Flip rather than overflow when the trigger is near a viewport edge. */
    position-try-fallbacks:
      block-start span-inline-end,
      block-end span-inline-start,
      block-start span-inline-start;
  }
}

.sk-popover__text {
  margin: 0;
  color: var(--sk-color-text-muted);
  font-size: var(--sk-text-sm);
  max-inline-size: 30ch;
}

/* ---------------------------------------------------------------------------
   Menu variant
--------------------------------------------------------------------------- */

.sk-popover__menu {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
}

.sk-popover__item {
  inline-size: 100%;
  border: 0;
  border-radius: var(--sk-radius-sm);
  background: none;
  color: var(--sk-color-text);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-sm);
  font-size: var(--sk-text-sm);

  /* Logical, so the menu reads correctly when the document flips to RTL. */
  text-align: start;
  min-block-size: var(--sk-size-tap-target);
}

.sk-popover__item:hover {
  background: var(--_item-bg-hover);
}

.sk-popover__item[data-tone="danger"] {
  color: var(--_danger-fg);
}

.sk-popover__item[data-tone="danger"]:hover {
  background: var(--_danger-bg-hover);
}

/* ---------------------------------------------------------------------------
   Motion

   All of it is opt-in (CLAUDE.md rule 4). Without this block the panel simply
   appears and disappears, which is a complete experience — base.css also
   clamps durations globally under `reduce`.
--------------------------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {
  .sk-popover__panel {
    opacity: 0;
    translate: 0 calc(-1 * var(--sk-space-2xs));
    transition:
      opacity var(--sk-motion-fast) var(--sk-ease-out),
      translate var(--sk-motion-fast) var(--sk-ease-out);
  }

  .sk-popover__panel:popover-open {
    opacity: 1;
    translate: 0 0;
  }

  /* The entry style. Without @starting-style the panel is simply open at full
     opacity on the first frame — no animation, nothing broken. */
  @starting-style {
    .sk-popover__panel:popover-open {
      opacity: 0;
      translate: 0 calc(-1 * var(--sk-space-2xs));
    }
  }

  /* display and overlay are discrete properties: without allow-discrete they
     flip at once and the panel vanishes before it can fade, so the exit
     animation — and only the exit — depends on this. */
  @supports (transition-behavior: allow-discrete) {
    .sk-popover__panel {
      transition:
        opacity var(--sk-motion-fast) var(--sk-ease-out),
        translate var(--sk-motion-fast) var(--sk-ease-out),
        display var(--sk-motion-fast) allow-discrete,
        overlay var(--sk-motion-fast) allow-discrete;
    }
  }
}

Browser support

Baseline newly available

Shipped in every major engine, but only recently — older versions need the fallback.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Popovernewly1161161251711612518.3
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
@starting-stylenewly11711712917.511712917.5
transition-behaviornewly11711712917.411712917.4
Anchor positioning3 of its partsnewly1291291472612914726

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).

PropertyDefaultControls
--sk-popover-panel-bg--sk-color-surface-raisedPanel background
--sk-popover-panel-fg--sk-color-textPanel text
--sk-popover-panel-border-color--sk-color-borderPanel border
--sk-popover-panel-radius--sk-radius-lgPanel corner radius
--sk-popover-panel-shadow--sk-shadow-lgPanel elevation
--sk-popover-panel-max-inline-size22remWidest the panel grows
--sk-popover-trigger-bg--sk-color-surfaceTrigger background
--sk-popover-trigger-bg-hover--sk-color-surface-raisedTrigger background on hover
--sk-popover-trigger-fg--sk-color-textTrigger text
--sk-popover-trigger-border-color--sk-color-border-strongTrigger border
--sk-popover-trigger-radius--sk-radius-mdTrigger corner radius
--sk-popover-item-bg-hover--sk-color-accent-subtleMenu item background on hover
--sk-popover-danger-fg--sk-color-dangerText of a data-tone="danger" item
--sk-popover-danger-bg-hover--sk-color-danger-subtleBackground 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

KeyBehaviour
TabMoves to the trigger
Enter / Space on the triggerOpens the panel; focus stays on the trigger
Tab while openMoves through the panel’s contents in DOM order
EscCloses the panel and returns focus to the trigger
Click outsideCloses 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 usedBaseline statusBehavior without it
Popover APInewlyNo 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-sizenewlyPanel becomes a sheet pinned to the bottom of the viewport. Fully usable; it just is not next to the trigger.
@starting-stylenewlyNo entry animation — the panel appears at full opacity.
transition-behavior: allow-discretenewlyNo 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.