Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Popover

Sides, alignment, and animation for any `[popover]` element.

Installation

pnpm dlx zazz-ui add popover
npx zazz-ui add popover
yarn dlx zazz-ui add popover
bunx zazz-ui add popover

Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Shipping

Orders placed before noon ship the same day.

<div>
  <button data-ui="button" type="button" popovertarget="popover-example">Show details</button>
  <div id="popover-example" popover="auto" data-popover-side="bottom" data-popover-align="start">
    <div
      style="
        --display: flex-col;
        --p: 3;
        --gap: 2;
        --max-w: 72;
      "
    >
      <strong>Shipping</strong>
      <p style="--text: var(--color-muted-foreground)">
        Orders placed before noon ship the same day.
      </p>
    </div>
  </div>
</div>
/**
 * popover.css: Shared [popover] primitive (Tier 3 owner for --ui-popover-*)
 *
 * @layer      variables, reset, components
 * @requires   _layers.css, _variables.css (--color-popover, --color-popover-foreground), _properties.css (--_*-resolved)
 * @uses       [popover], popovertarget, :popover-open: Popover API (native, no polyfill)
 * @uses       @starting-style + allow-discrete: enter/exit opacity/transform
 * @uses       anchor-name, position-anchor, position-area, position-try-fallbacks:
 *             anchor positioning (gated behind @supports; Safari 26.x falls
 *             back to the UA-centered popover)
 * @tokens     --ui-popover-* (Tier 3 owner; @layer variables)
 * @presets    data-popover-side="top | bottom | left | right", data-popover-align="start | center | end"
 *             on the [popover] element (the identity is the native attribute)
 * @consumedby select.css (picker placement), menu.css, navigation-menu.css, tooltip.css, toaster.css,
 *             autocomplete.css, combobox.css, command.css
 */

@layer variables {
  :root {
    /* Styles */
    --ui-popover-border-color: var(--color-border);
    --ui-popover-rounded: var(--radius-md);
    --ui-popover-button-rounded: var(--radius-sm);
    --ui-popover-shadow: var(--shadow-md);
    --ui-popover-p: calc(var(--spacing) * 1.5);
    --ui-popover-min-w: var(--article-xs);
    --ui-popover-max-w: 100vi;

    /* Reveal transitions/animations */
    --ui-popover-scale--start: 0.98;
    --ui-popover-translate--start: 0, 0, 0;
    --ui-popover-opacity--start: 0;
    --ui-popover-scale--end: 1;
    --ui-popover-translate--end: 0, 0, 0;
    --ui-popover-opacity--end: 1;

    /* Positioning */
    --ui-popover-origin: top center;
    --ui-popover-my: calc(var(--spacing) * 1.5);
    --ui-popover-mx: 0;
    --ui-popover-position-area: block-end inline-start;
    --ui-popover-position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
  }
}

@layer reset {
  /* ===========================================================================
  POPOVER ELEMENT ENHANCEMENTS
  Wires native open/close motion and anchor positioning; surface chrome is in
  @layer ui.components below.
  =========================================================================== */

  :where([popover]) {
    color: var(--color-popover-foreground);
    background-color: var(--color-popover);

    align-content: start;
    transform-origin: var(--ui-popover-origin);
    border-radius: var(--_rounded-resolved, var(--ui-popover-rounded));
    box-shadow: var(--_shadow-resolved, var(--ui-popover-shadow));
    opacity: var(--ui-popover-opacity--start);
    transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-popover-translate--start));
    transition:
      opacity var(--default-transition-duration) var(--default-transition-timing-function),
      transform var(--default-transition-duration) var(--default-transition-timing-function),
      padding var(--default-transition-duration) var(--default-transition-timing-function),
      block-size var(--default-transition-duration) var(--default-transition-timing-function),
      inline-size var(--default-transition-duration) var(--default-transition-timing-function),
      overlay var(--default-transition-duration) var(--default-transition-timing-function)
        allow-discrete,
      display var(--default-transition-duration) var(--default-transition-timing-function)
        allow-discrete;
  }

  /* Anchor positioning tethers the popover to its trigger and flips it away
     from viewport edges. The gate keeps Safari 26.x on the UA-centered popover
     (inset: 0 + margin: auto) instead of a mispositioned top-left box.
     Detection key is anchor-name, the API's entry property. */
  @supports (anchor-name: --zazz) {
    :where([popover]) {
      position: fixed;
      inset: auto;
      --_my-len: var(--_my-resolved, var(--ui-popover-my));
      --_my-num: var(--_my-resolved, var(--ui-popover-my));
      margin-block: calc(var(--_my-len) + var(--_my-num) * var(--spacing));
      --_mx-len: var(--_mx-resolved, var(--ui-popover-mx));
      --_mx-num: var(--_mx-resolved, var(--ui-popover-mx));
      margin-inline: calc(var(--_mx-len) + var(--_mx-num) * var(--spacing));
      position-area: var(--ui-popover-position-area);
      position-try-fallbacks: var(--ui-popover-position-try-fallbacks);
    }
  }

  /* Closed popovers keep the UA's display: none; allow-discrete animates the switch. */
  [popover]:popover-open {
    display: grid;
    align-content: start;
    opacity: var(--ui-popover-opacity--end);
    transform: scale(var(--ui-popover-scale--end)) translate3d(var(--ui-popover-translate--end));

    @starting-style {
      opacity: var(--ui-popover-opacity--start);
      transform: scale(var(--ui-popover-scale--start))
        translate3d(var(--ui-popover-translate--start));
    }
  }
}

@layer ui.components {
  /* ===========================================================================
  POPOVER SURFACE — shared chrome for every [popover] consumer
  =========================================================================== */

  [popover] {
    border: 1px solid var(--_border-color-resolved, var(--ui-popover-border-color));
    --_p-len: var(--_p-resolved, var(--ui-popover-p));
    --_p-num: var(--_p-resolved, var(--ui-popover-p));
    padding: calc(var(--_p-len) + var(--_p-num) * var(--spacing));
    --_min-w-len: var(--_min-w-resolved, var(--ui-popover-min-w));
    --_min-w-num: var(--_min-w-resolved, var(--ui-popover-min-w));
    min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
    --_max-w-len: var(--_max-w-resolved, var(--ui-popover-max-w));
    --_max-w-num: var(--_max-w-resolved, var(--ui-popover-max-w));
    max-inline-size: calc(var(--_max-w-len) + var(--_max-w-num) * var(--spacing));
  }

  [popover] [data-ui~="button"] {
    --ui-button-rounded: var(--ui-popover-button-rounded);
  }

  /* ===========================================================================
  POSITIONING — side × align

  The single placement matrix for every anchored surface. It assigns the
  --ui-popover-position-* tokens; each surface applies them itself (popover
  above, ::picker(select) in select.css) because the properties are gated on
  anchor support. Zero specificity (:where) so components can override a cell.
  A <select> joins from select.css: custom properties set on it inherit into
  ::picker(select).
  =========================================================================== */

  :where([popover])[data-popover-side="bottom"]:not([data-popover-align]),
  :where([popover])[data-popover-side="bottom"][data-popover-align="start"] {
    --ui-popover-position-area: block-end span-inline-end;
    --ui-popover-origin: top center;
    --ui-popover-my: calc(var(--spacing) * 1.5);
    --ui-popover-mx: 0;
  }

  :where([popover])[data-popover-side="bottom"][data-popover-align="center"] {
    --ui-popover-position-area: block-end span-all;
    --ui-popover-origin: top center;
    --ui-popover-my: calc(var(--spacing) * 1.5);
    --ui-popover-mx: 0;
  }

  :where([popover])[data-popover-side="bottom"][data-popover-align="end"] {
    --ui-popover-position-area: block-end span-inline-start;
    --ui-popover-origin: top center;
    --ui-popover-my: calc(var(--spacing) * 1.5);
    --ui-popover-mx: 0;
  }

  :where([popover])[data-popover-side="top"]:not([data-popover-align]),
  :where([popover])[data-popover-side="top"][data-popover-align="start"] {
    --ui-popover-position-area: block-start span-inline-end;
    --ui-popover-origin: bottom center;
    --ui-popover-my: calc(var(--spacing) * 1.5);
    --ui-popover-mx: 0;
  }

  :where([popover])[data-popover-side="top"][data-popover-align="center"] {
    --ui-popover-position-area: block-start span-all;
    --ui-popover-origin: bottom center;
    --ui-popover-my: calc(var(--spacing) * 1.5);
    --ui-popover-mx: 0;
  }

  :where([popover])[data-popover-side="top"][data-popover-align="end"] {
    --ui-popover-position-area: block-start span-inline-start;
    --ui-popover-origin: bottom center;
    --ui-popover-my: calc(var(--spacing) * 1.5);
    --ui-popover-mx: 0;
  }

  :where([popover])[data-popover-side="right"]:not([data-popover-align]),
  :where([popover])[data-popover-side="right"][data-popover-align="start"] {
    --ui-popover-position-area: inline-end span-block-end;
    --ui-popover-origin: bottom left;
    --ui-popover-my: 0;
    --ui-popover-mx: calc(var(--spacing) * 1.5);
  }

  :where([popover])[data-popover-side="right"][data-popover-align="center"] {
    --ui-popover-position-area: inline-end span-all;
    --ui-popover-origin: center left;
    --ui-popover-my: 0;
    --ui-popover-mx: calc(var(--spacing) * 1.5);
  }

  :where([popover])[data-popover-side="right"][data-popover-align="end"] {
    --ui-popover-position-area: inline-end span-block-start;
    --ui-popover-origin: top left;
    --ui-popover-my: 0;
    --ui-popover-mx: calc(var(--spacing) * 1.5);
  }

  :where([popover])[data-popover-side="left"]:not([data-popover-align]),
  :where([popover])[data-popover-side="left"][data-popover-align="start"] {
    --ui-popover-position-area: inline-start span-block-end;
    --ui-popover-origin: bottom right;
    --ui-popover-my: 0;
    --ui-popover-mx: calc(var(--spacing) * 1.5);
  }

  :where([popover])[data-popover-side="left"][data-popover-align="center"] {
    --ui-popover-position-area: inline-start span-all;
    --ui-popover-origin: center right;
    --ui-popover-my: 0;
    --ui-popover-mx: calc(var(--spacing) * 1.5);
  }

  :where([popover])[data-popover-side="left"][data-popover-align="end"] {
    --ui-popover-position-area: inline-start span-block-start;
    --ui-popover-origin: top right;
    --ui-popover-my: 0;
    --ui-popover-mx: calc(var(--spacing) * 1.5);
  }
}

Attributes

popover attributes
AttributeKindValuesNotes
data-popover-alignPresetcenter end startPreset: a named bundle of values.
data-popover-sidePresetbottom left right topPreset: a named bundle of values.

Hooks

Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.

popover hooks
HookDefaultState
--ui-popover-border-colorvar(--color-border)—
--ui-popover-button-roundedvar(--radius-sm)—
--ui-popover-max-w100vi—
--ui-popover-min-w0—
--ui-popover-mxcalc(var(--spacing) * 1.5)—
--ui-popover-mycalc(var(--spacing) * 1.5)—
--ui-popover-opacity--end1end
--ui-popover-opacity--start0start
--ui-popover-origintop center—
--ui-popover-p0—
--ui-popover-position-areablock-end span-inline-end—
--ui-popover-position-try-fallbacksflip-inline, flip-block, flip-inline flip-block—
--ui-popover-roundedvar(--radius-md)—
--ui-popover-scale--end1end
--ui-popover-scale--start1start
--ui-popover-shadowvar(--ui-navigation-menu-popover-shadow)—
--ui-popover-translate--end0, 0, 0end
--ui-popover-translate--start0, 0, 0start