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

registry:ui · ARIA

dropdown menu — the menu the browser never shipped

The popover laws carry the surface: popover=auto light dismiss, Escape, top layer, CSS Anchor Positioning. The component adds the menu keyboard contract the platform lacks — opening focuses item 1, arrows/Home/End walk with wrapping, 500ms typeahead jumps by label, and selection closes with focus restored to the trigger.

install — dropdown-menu

$npx jixoai-ui add dropdown-menu

https://ui.jixoai.com/r/dropdown-menu.json

role=menu · menuitem typeahead focus restore on Esc/select separators are plain <hr>

overview

Overview

Native surface, component contract: the platform owns the panel behavior, the component owns the menu keyboard walk — and the family is a primitive other menus compose.

The platform owns the surface behavior: popover="auto" gives light dismiss, Escape and the top layer; CSS Anchor Positioning places the panel (zero JS geometry, flip fallbacks); the panel paints nothing itself — the floating-surface vocabulary (bezel fill, seam, scroll ring) carries it, and the slide rides the shared surface-motion kernel (one WAAPI --jx-p number).

The component owns the menu contract: opening focuses the FIRST item, ↑/↓ walk and wrap, Home/End jump the ends, a 500ms typeahead jumps by label, and selection closes with focus restored to the trigger. Items are whatever the consumer nests — any [role=menuitem] joins the walk through DOM delegation scoped to the nearest menu (a nested submenu family never leaks), separators are plain <hr>, and the walk's highlight is a paint-only data-walk-active that never touches a consumer's aria-current.

The family is a primitive other menus compose — three real composers (import-grep receipt): the breadcrumb dropdown (breadcrumb-dropdown.svelte), button-group's overflow menu (button-group.svelte) and the component-canvas playground's axis menus (canvas-playground.svelte) all mount it. Two siblings deliberately DO NOT: menubar and navigation-menu duplicate the menu contract by hand — their headers say so ("duplicated deliberately: registry items stay independent, no hidden coupling", menubar.svelte; navigation-menu declares itself "an independent thin" navigation contract where "actions belong to dropdown-menu") — registry items stay independent. The eight axes resolve on both ends — density rides an inherit-then-provide provider (the anchor and the panel carry the rung; items resolve through the same contract), radius makes the menu the concentric anchor or consumes the broadcast protocol (吃也供, supply-and-consume), and the panel's elevation is the family's own level2. The axis grammar lives on the universal props page.

usage

Usage

Compose a trigger with menu items; separators remain native hr elements.

DropdownMenu usage
<script lang="ts">
  import DropdownMenu from '@ui/dropdown-menu.svelte';
  import DropdownMenuItem from '@ui/dropdown-menu-item.svelte';
</script>

<DropdownMenu id="actions" triggerLabel="Actions">
  <DropdownMenuItem onclick={rename}>Rename…</DropdownMenuItem>
  <DropdownMenuItem onclick={duplicate}>Duplicate</DropdownMenuItem>
  <hr />  <!-- separator: the native element already means it -->
  <DropdownMenuItem destructive onclick={del}>Delete</DropdownMenuItem>
</DropdownMenu>

api

Props

The root table renders from the GENERATED meta + curation; the eight axis rows split into the shared section beneath (the item is a separate component — its two props ride the hand table below).

Properties

PropertyTypeDefaultDescription
id * string—Stable mount-stable id wiring the trigger (popovertarget), the panel (popover id) and the CSS anchor name (--jx-menu-<id>) — wired once, never reactive.
triggerLabel string''The default trigger button's label (ignored when the `trigger` snippet is given).
placement 'bottom' | 'bottom-end' | 'bottom-start' | 'top' | 'top-end' | 'top-start''bottom-end'Anchor placement for the panel — position-area semantics (bottom/bottom-start/bottom-end/top/top-start/top-end; the span table pairs each logical placement with the span that measures as its intended alignment).
variant DropdownMenuSurfaceVariantOwn default, not ambientFloating-surface paint for the panel (the dialog/sheet surface grammar). Literal slot with own 'auto' (acrylic unless the environment asks for reduced transparency) — declared own, no axis yet.
trigger Snippet—Custom trigger snippet rendered inside the anchor wrapper; its popovertarget button is adopted for aria mirroring and focus restoration.
panelClass string''Class passthrough onto the panel element, after the family atoms.
onToggle (open: boolean) => void—Receives every native open-state change (the toggle seam; state read live from :popover-open).
children Snippet—The menu items — DropdownMenuItem pairs or any [role=menuitem] elements; the keyboard walk joins them all through scoped DOM delegation.

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)

DropdownMenuItem

PropertyTypeDefaultDescription
destructive booleanfalseDestructive paint (the frozen --jx-destructive label; the raw destructive pair on hover/walk) while preserving menuitem semantics.
density DensityLane | QueryResult<DensityLane>ambient scopeExplicit override resolved through the same family contract; an item's own lane beats the provided tier.
...rest HTMLButtonAttributesspreadForwarded to the item button (onclick, disabled…); Omit<…, 'color'> — the §1 native-collision rule.

axes

The eight axes on dropdown-menu

Four axes paint this family and four supply. CONSUMED: density (the trigger/item rhythm lanes through the data-density scope, with the inherit-then-provide provider), shape (the panel's corner-shape), radius (the §3/§14 chain — explicit anchor or the concentric auto calc), elevation (the §7 pair, the family's own level2). SUPPLY-ONLY with negative-grep receipts: size, color, motion. theme splits — the panel flips under .dark while the trigger chrome stays frozen. The carriers stamp the PANEL (the promoted root is self-carried — the portal law), so every stamp below is greppable on the panel element.

Reading the table: Property is the axis, Type is the real carrier or consumption it drives on THIS family, Default is the lane default — the named steps, number unit, and consumption are in each description.

PropertyTypeDefaultDescription
size 'small' | 'medium' | 'large' | 'auto' | number'auto'SUPPLY-ONLY — stamps --jx-size-effective; no family css reads it (grep receipt: zero readers in ui/dropdown-menu/). The menu's type is the fixed 13px .jx-menu law and the trigger rides the density channels — this stamp moves neither. Number unit: px.
shape 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto'CONSUMED — the panel silhouette: .jx-menu paints corner-shape: var(--jx-shape-effective, round), so a named step re-shapes the bezel live (round default; squircle superellipse where corner-shape is supported). Number unit: none.
radius 'small' | 'medium' | 'large' | 'auto' | number'auto'CONSUMED both ways (the §3/§14 chain). An explicit lane makes the menu the CONCENTRIC ANCHOR: the root stamps --jx-radius-consumed = --jx-radius-effective × --jx-radius-factor-effective. auto consumes the broadcast against the panel's OWN ancestors — the css fallback is the §3 concentric calc verbatim (max(0px, effective − inset-effective) × factor), so at the root invariants the panel honestly computes 0. Number unit: px.
density 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings)'auto'CONSUMED — the menu's rhythm lanes: trigger and items read --jx-hit (min-block-size), --jx-gap, --jx-inset (padding), --jx-text/--jx-line (type) — the named rung stamps data-density on the anchor AND the panel and the scope block re-bases them in scope. The menu is also a STRUCTURAL PROVIDER: the density lane is inherit-then-provide (the eager-capture law), so items resolve their own stamp through the same contract and composers inherit the menu's scope. The NUMBER lane carries no rung (the input-group narrowing at the legacy edge) — the coefficient rides the carriers and re-bases nothing (the declaring-element law). Number unit: coefficient.
color 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto'SUPPLY-ONLY — stamps --jx-color-effective; no family css reads it (grep receipt: zero readers in ui/dropdown-menu/). The menu's ink rides the theme's raw tokens (--popover-foreground, the destructive pair); hue that paints comes from the theme or the jx-hue-* injection. Number unit: hue degrees.
theme 'light' | 'dark' | 'system' | 'auto''auto'PARTIAL re-theme, split by the declaring-selector grep. FLIPS under the panel's .dark (raw tokens): the panel ink (--popover-foreground) and with it the walk highlight (color-mix over currentColor), the focus ring (--ring), the destructive hover pair, the bezel SEAM (--border) — and the bezel FILL, though not through --popover: the own level2 stamp is unconditional, so .jx-surface-body's fill resolves the elevation ladder (--jx-elevation-level2-surface → --surface-container-low: light oklch(0.96), dark oklch(0.185); --popover is only the stamp-less fallback — the discriminator is the dark fill 0.185, not --popover's 0.3211), and the elevation shadow recipes re-declare under .dark as WHITE (hsl(0 0% 100% / .16)). STAYS FROZEN (stylex :root aliases): the trigger chrome (--jx-background/--jx-border/--jx-foreground, hover --jx-muted) — the trigger keeps the page profile while its panel re-themes. light and system stamp nothing — tree inheritance.
elevation 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'level2' · Own default, not ambientCONSUMED via the §7 pair — the panel's floating surface. The slot's OWN is level2 (3dp, M3's menu rung — the panel's historic z-feel; the census batch C mapping); an explicit level steps the theme's table (level4 = 8dp + surface-container-high) and the number lane snaps DOWN between rungs. The stamp is UNCONDITIONAL — the own default always lands, so the panel fill resolves the ladder (--jx-elevation-level2-surface → --surface-container-low; --popover is only the stamp-less fallback) and the shadow rides the level's recipe. The root stamps the composed --jx-elevation-surface/--jx-elevation-shadow pair onto the panel. Number unit: dp.
motion 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto'SUPPLY-ONLY — stamps --jx-motion-effective; no family css reads it (grep receipt: zero readers). The panel's real motion is the shared surface-motion kernel (one WAAPI --jx-p number, css formulas) and the caret/item transitions are 150ms/100ms literals with the reduced-motion kill — the axis has no family-local kernel to step. Number unit: coefficient.

Deviations, cited: the adoption is the census batch C row (LANDED 09d64fb0 — the M3 two-layer elevation recipe through the §12 indirection at light and dark, the number lane snapping DOWN between rungs, and the PORTAL ROOT self-carried; openspec/changes/ explicit-props/research/migration-census.md). The elevation OWN — level2, 3dp, M3's menu rung — is that row's historic z-feel mapping. The §1 collision rule: the item extends HTMLButtonAttributes Omit<'color'>, the root destructures every axis name — the native elements never receive them, and everything else rides the rest spreads.

the eight axes on dropdown-menu
<!-- radius: an explicit lane makes the menu the CONCENTRIC
     ANCHOR (effective × the §14 factor); auto consumes the
     broadcast against the panel's own ancestors -->
<DropdownMenu id="r-auto" triggerLabel="radius auto">…</DropdownMenu>
<DropdownMenu id="r-md" triggerLabel="radius medium" radius="medium">…</DropdownMenu>

<!-- elevation: the menu panel's OWN is level2 (3dp, M3's menu rung);
     an explicit level steps the theme's elevation table -->
<DropdownMenu id="e-l4" triggerLabel="level4" elevation="level4">…</DropdownMenu>

<!-- theme: PARTIAL re-theme — the panel (bezel, ink, focus ring,
     destructive pair) flips under .dark; the trigger chrome rides the
     frozen stylex aliases and keeps the page profile -->
<DropdownMenu id="t-dark" triggerLabel="dark island" theme="dark">…</DropdownMenu>

dropdown-menu · the consumed lanes

radius auto — the concentric calc against the panel's own ancestors
radius="medium" — the menu is the CONCENTRIC ANCHOR
level2 · own — inspect the panel's --jx-elevation-effective: 3
elevation="level4" — the table steps (8dp + surface-container-high)
theme="dark" — the panel flips; the trigger chrome stays frozen

Open a panel and read the stamps on the panel element itself — the style attr carries the carriers, the radius consumption and the elevation pair (the portal law). The dark island's bezel and ink follow the island while its trigger keeps the page profile (the theme table's split).

dropdown-menu-axes.svelte svelte
<!-- radius: an explicit lane makes the menu the CONCENTRIC
     ANCHOR (effective × the §14 factor); auto consumes the
     broadcast against the panel's own ancestors -->
<DropdownMenu id="r-auto" triggerLabel="radius auto">…</DropdownMenu>
<DropdownMenu id="r-md" triggerLabel="radius medium" radius="medium">…</DropdownMenu>

<!-- elevation: the menu panel's OWN is level2 (3dp, M3's menu rung);
     an explicit level steps the theme's elevation table -->
<DropdownMenu id="e-l4" triggerLabel="level4" elevation="level4">…</DropdownMenu>

<!-- theme: PARTIAL re-theme — the panel (bezel, ink, focus ring,
     destructive pair) flips under .dark; the trigger chrome rides the
     frozen stylex aliases and keeps the page profile -->
<DropdownMenu id="t-dark" triggerLabel="dark island" theme="dark">…</DropdownMenu>
one real query() case
<script lang="ts">
  import DropdownMenu from '@ui/dropdown-menu.svelte';
  import DropdownMenuItem from '@ui/dropdown-menu-item.svelte';
  import { query } from '@lib/universal-props-query.svelte';
  import type { DensityLane } from '@lib/defaults.svelte';
</script>

<DropdownMenu
  id="responsive"
  triggerLabel="responsive menu"
  density={query<{ sm: DensityLane }, DensityLane>({ sm: 'small' }, 'large')}
>
  <DropdownMenuItem>Rename…</DropdownMenuItem>
</DropdownMenu>

dropdown-menu · query()

Media keys are min-width: below 40rem the base applies — the large rung, the touch-size trigger and items; at 40rem and wider the sm case wins and both compact. Resize across 40rem.

dropdown-menu-query-demo.svelte svelte
<script lang="ts">
  import DropdownMenu from '@ui/dropdown-menu.svelte';
  import DropdownMenuItem from '@ui/dropdown-menu-item.svelte';
  import { query } from '@lib/universal-props-query.svelte';
  import type { DensityLane } from '@lib/defaults.svelte';
</script>

<DropdownMenu
  id="responsive"
  triggerLabel="responsive menu"
  density={query<{ sm: DensityLane }, DensityLane>({ sm: 'small' }, 'large')}
>
  <DropdownMenuItem>Rename…</DropdownMenuItem>
</DropdownMenu>
TokenDefaultSource
--jx-hit24 / 28 / 32 / 40 / 48px (2xs → lg)density
--jx-insetrung scale × coefficientdensity
--jx-gaprung scale × coefficientdensity
--jx-text / --jx-line10 / 11 / 12 / 13 / 15px · 14 / 16 / 18 / 20 / 24px (2xs → lg)density
--jx-menu-gap / --jx-menu-pad8px / 4pxstructural
TokenDefaultSource
--jx-elevation-level2-surfaceFLIPS under .darkstructural
--jx-elevation-level2/4-shadowFLIPS: black → white recipesstructural
--popover / --borderFLIPS under .darkstructural
--popover-foregroundFLIPS under .darkstructural
--ringFLIPS under .darkstructural
--destructive / --destructive-foregroundFLIPS under .darkstructural
--jx-background / --jx-border / --jx-foreground / --jx-mutedFROZEN at :rootstructural
--jx-destructiveFROZEN at :rootstructural
--jx-press-shadow / -hover / -activeshadow-xs / -sm / -sm-pressstructural

a11y

Accessibility

The menu follows the ARIA menu keyboard contract while keeping light dismiss native.

Keyboard

KeyAction
Arrow keysMove between menu items and wrap at the ends.
Home / EndJump to the first or last enabled item.
Enter / SpaceActivate the focused item and close the menu.
EscapeClose and restore focus to the trigger.

ARIA

AttributeValueDescription
rolemenu / menuitemExposes the menu and its actionable items.
data-walk-active(paint-only)The keyboard walk’s highlight — a visual state attribute on the walked item; it never rewrites aria-current (a static aria-current="page" on a raw item is the author’s semantics and stays).
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h