jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

registry:ui · NativeHTML

popover — declarative, zero script

The native Popover API does everything: the panel carries popover="auto" and the trigger is wired with popovertarget, so light dismiss, Escape, aria-expanded, and top-layer rendering come from the browser. The default trigger path runs zero listeners and zero positioning script — the browser owns open/close, light dismiss, and the top layer; the component's script is one aria toggle seam plus the shared surface-motion kernel (view source and count).

install — popover

$npx jixoai-ui add popover

https://ui.jixoai.com/r/popover.json

popover="auto" popovertarget trigger light dismiss · Escape top layer 0 positioning JS

usage

Usage

Menu rows repeat popovertarget to close on select — still zero JavaScript on the close path.

Popover usage
<script lang="ts">
  import Popover from '@ui/popover.svelte';
</script>

<!-- menu rows repeat popovertarget to close on select — still zero JS -->
<Popover id="actions" triggerLabel="Actions">
  <div class="flex flex-col">
    <button type="button" class="pop-row" popovertarget="actions">Rename…</button>
    <button type="button" class="pop-row" popovertarget="actions">Archive</button>
  </div>
</Popover>

popover

popover="auto" + popovertarget: light dismiss, Escape, aria-expanded, and top-layer rendering are the browser's — the panel anchors to the trigger through CSS Anchor Positioning. Relabel the trigger from the Playground.

popover-usage.svelte svelte
<script lang="ts">
  import Popover from '@ui/popover.svelte';
</script>

let triggerLabel = $state("Actions");
</script>

<!-- rows repeat popovertarget to close on select — still zero JS -->
<Popover id="actions" {triggerLabel} variant="auto">
  <div class="flex w-52 flex-col">
    <button type="button" class="pop-row" popovertarget="actions">Rename…</button>
    <button type="button" class="pop-row" popovertarget="actions">Archive</button>
  </div>
</Popover>

demo

Menu type

Action rows inside the children snippet. Each row repeats popovertarget so selecting closes the panel — still no JavaScript on the close path; only the row's own onclick does its work.

last action: —

Click anywhere outside, press Escape, or pick a row — three exits, all native. While open, the caret flips via :has(+ :popover-open) where the engine supports it.

usage
<script lang="ts">
  import Popover from '@ui/popover.svelte';
</script>

<!-- menu rows repeat popovertarget to close on select — still zero JS -->
<Popover id="actions" triggerLabel="Actions">
  <div class="flex flex-col">
    <button type="button" class="pop-row" popovertarget="actions">Rename…</button>
    <button type="button" class="pop-row" popovertarget="actions">Archive</button>
  </div>
</Popover>

demo

Card type

The children snippet is the whole panel body, so richer content — status facts and a link out — composes with the same one-element base.

registry status

Components are served as same-source files from this domain — nothing here is a runtime dependency.

items
12
runtime deps
0
license
MIT
usage
<Popover id="status" triggerLabel="Registry status">
  <p class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">
    registry status
  </p>
  <p>Any content — the snippet is the whole panel body.</p>
</Popover>

W3C foundation

What the platform gives, what we add

The component is styling and structure over two native attributes. Everything behavioral is the browser's; the one design decision we own is the placement strategy — anchored placement SHIPPED 2026-08-21 (the placement prop, nine positions, demoed above).

platform-native, free

  • popover="auto" — light dismiss: outside click closes (Tab-out does NOT close on Chromium — an interop gap; Escape and click-out are the reliable exits); only one auto popover stays open at a time
  • popovertarget={id} — declarative trigger wiring; Enter/Space toggle, and the browser exposes aria-expanded on the button
  • top layer — the panel renders above sticky headers, transforms, and open dialogs (engine-dependent at the top layer's hit-testing edge: true on system Chrome, not on Chrome for Testing 153 — the z-order seam is the platform's, re-verify per engine)
  • Escape closes natively; focus returns to the trigger
  • ::backdrop ships with popovers too — kept transparent so dismissing never dims the page

jixoai strategy & extensions

  • zero positioning script — the open/close path is native; the component's script is the aria toggle seam + the motion kernel (no effects on the trigger path)
  • placement v1 — authored centering: inset-area: center with an inset + margin fallback (the UA default centers via margin: auto)
  • SHIPPED: anchored placement next to the trigger — CSS Anchor Positioning (anchor-name + position-anchor/inset-area) behind the live placement prop (nine positions, demoed above)
  • floating-surface law: the hard offset shadow is a REAL ::after layer; @starting-style + allow-discrete run the open/close pull-apart — layers press together, then separate into elevation
  • surface variants: variant="solid | acrylic | auto" — solid keeps the opaque surface with the original-color translucent shadow (black in light mode, white in dark); acrylic is a dual-layer backdrop-filter (surface blur + shadow-layer brightness); auto picks acrylic unless the environment asks for reduced transparency
  • extension: popover="manual" variant for toast-like panels that ignore light dismiss

types

Popover variants

Panel content is free; the variant axes are surface paint, placement, and the trigger.

variant

solid | acrylic | auto (default) — acrylic is a dual-layer backdrop-filter; auto defers to the environment's transparency preference.

placement — nine positions

The six classic sides plus left | right | center; default bottom-end — under the trigger, right edges aligned.

trigger

The default button carries popovertarget; a custom trigger snippet renders your own control (drive it via the imperative handle).

a11y

Accessibility

Light dismiss, Escape, and top-layer focus order are the browser's; one native toggle seam mirrors aria-expanded.

Keyboard

KeyAction
Enter / SpaceToggle the panel from the popovertarget trigger (native button)
EscapeClose the panel; focus returns to the trigger
TabMoves through the panel body content while open

ARIA

AttributeValueDescription
aria-expandedtrue/falseOn the default trigger — mirrored live from :popover-open by the toggle seam.
popover"auto"Light dismiss (outside click / focus loss) and one-at-a-time are native.
position-visibilityanchors-visibleA panel whose anchor scrolled away hides instead of floating stale.

theming

Density and tokens

The anchored panel rides the shared motion kernel; the scroll ring's padding is token-overridable.

xs

Density-FLAT by family mechanism (measured): the scope stamps re-derive on the wrapper (the --jx-hit arithmetic 28/32/40/48) but this family's trigger and panel consume no density lane — the trigger stays 43px and the panel pad 12px 14px across every rung; each rung's panel is its OWN instance (density-pop-xs/sm/default/lg — per-rung ids, LAW #19).

sm

Density-FLAT by family mechanism (measured): the scope stamps re-derive on the wrapper (the --jx-hit arithmetic 28/32/40/48) but this family's trigger and panel consume no density lane — the trigger stays 43px and the panel pad 12px 14px across every rung; each rung's panel is its OWN instance (density-pop-xs/sm/default/lg — per-rung ids, LAW #19).

default

Density-FLAT by family mechanism (measured): the scope stamps re-derive on the wrapper (the --jx-hit arithmetic 28/32/40/48) but this family's trigger and panel consume no density lane — the trigger stays 43px and the panel pad 12px 14px across every rung; each rung's panel is its OWN instance (density-pop-xs/sm/default/lg — per-rung ids, LAW #19).

lg

Density-FLAT by family mechanism (measured): the scope stamps re-derive on the wrapper (the --jx-hit arithmetic 28/32/40/48) but this family's trigger and panel consume no density lane — the trigger stays 43px and the panel pad 12px 14px across every rung; each rung's panel is its OWN instance (density-pop-xs/sm/default/lg — per-rung ids, LAW #19).

TokenDefaultSource
--jx-pop-{id}anchor-namecomponent
--jx-pop-gap0px (gap prop)component
--jx-pop-pad / -inline12px 14px / 14pxcomponent
--jx-surface-in-x/y · -ox/-oydirection vectorscomponent
--jx-scrollbar-thinthin lanecomponent
--jx-text11 / 12 / 13 / 15pxdensity
--jx-hit28 / 32 / 40 / 48pxdensity

axes

Universal props

The eight-axis surface (explicit-props): size · shape · radius · density · color · theme · elevation · motion — each axis takes named steps, auto (inherit the ambient context; stamps nothing), an exact number (px · coefficient · dp · hue per axis), or query() for responsive/container-conditional values. The anchored panel carries its OWN elevation — level2 (3dp, M3's menu rung): the theme's level table pairs the shadow recipe with the ladder rung; an explicit lane or a dp number steps both. The carriers stamp the promoted panel root — self-carried across the top layer.

Popover · universal props

3dp + the surface-container-low rung.

6dp + the surface-container rung.

Exact dp snaps down — 8dp IS level4.

The concave: inset shadow + the deepest rung.

popover-universal.svelte svelte
<Popover id="p-default" triggerLabel="level2 · default">…</Popover>
<Popover id="p-l3" triggerLabel="level3" elevation="level3">…</Popover>
<Popover id="p-dp" triggerLabel="8dp" elevation={8}>…</Popover>
<Popover id="p-concave" triggerLabel="level-1" elevation="level-1">…</Popover>

api

API

Ten props plus the bind:this imperative handle — no controlled state model.

Properties

PropertyTypeDefaultDescription
id * string—Popover id: popovertarget association + the CSS anchor name.
triggerLabel string''Default trigger button label; ignored when a trigger snippet is given.
placement 'bottom' | 'bottom-end' | 'bottom-start' | 'top' | 'top-end' | 'top-start' | 'left' | 'right' | 'center''bottom-end'The INITIAL anchored position; chosen once at open, never re-evaluated while open.
variant 'solid' | 'acrylic' | 'auto''auto' · Own default, not ambientFloating-surface paint.
tryFallbacks string''Raw position-try value — custom @position-try idents replace the default flip series.
gap number | string—Anchor gap, margin semantics — number = uniform px; shorthand gaps only the facing side. Invalid input is ignored.
trigger Snippet—Custom trigger control; anchoring stays component-owned.
panelClass string''Appended to the panel (width, grid, tokens — never anchoring).
onToggle (open: boolean) => void—Mirrors the native toggle event; the only open-state source of truth.
children * Snippet—The whole panel body.
bind:this { show, hide, toggle }—Imperative handle — thin native passthroughs for exceptional triggers.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h