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.
$npx jixoai-ui add dropdown-menu
https://ui.jixoai.com/r/dropdown-menu.json
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.
<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
| Property | Type | Default | Description |
|---|---|---|---|
| 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 | DropdownMenuSurfaceVariant | Own default, not ambient | Floating-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
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
DropdownMenuItem
| Property | Type | Default | Description |
|---|---|---|---|
| destructive | boolean | false | Destructive paint (the frozen --jx-destructive label; the raw destructive pair on hover/walk) while preserving menuitem semantics. |
| density | DensityLane | QueryResult<DensityLane> | ambient scope | Explicit override resolved through the same family contract; an item's own lane beats the provided tier. |
| ...rest | HTMLButtonAttributes | spread | Forwarded 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.
| Property | Type | Default | Description |
|---|---|---|---|
| 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 ambient | CONSUMED 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.
<!-- 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
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).
<!-- 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><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>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.
| Token | Default | Source |
|---|---|---|
| --jx-hit | 24 / 28 / 32 / 40 / 48px (2xs → lg) | density |
| --jx-inset | rung scale × coefficient | density |
| --jx-gap | rung scale × coefficient | density |
| --jx-text / --jx-line | 10 / 11 / 12 / 13 / 15px · 14 / 16 / 18 / 20 / 24px (2xs → lg) | density |
| --jx-menu-gap / --jx-menu-pad | 8px / 4px | structural |
| Token | Default | Source |
|---|---|---|
| --jx-elevation-level2-surface | FLIPS under .dark | structural |
| --jx-elevation-level2/4-shadow | FLIPS: black → white recipes | structural |
| --popover / --border | FLIPS under .dark | structural |
| --popover-foreground | FLIPS under .dark | structural |
| --ring | FLIPS under .dark | structural |
| --destructive / --destructive-foreground | FLIPS under .dark | structural |
| --jx-background / --jx-border / --jx-foreground / --jx-muted | FROZEN at :root | structural |
| --jx-destructive | FROZEN at :root | structural |
| --jx-press-shadow / -hover / -active | shadow-xs / -sm / -sm-press | structural |
a11y
Accessibility
The menu follows the ARIA menu keyboard contract while keeping light dismiss native.
Keyboard
| Key | Action |
|---|---|
| Arrow keys | Move between menu items and wrap at the ends. |
| Home / End | Jump to the first or last enabled item. |
| Enter / Space | Activate the focused item and close the menu. |
| Escape | Close and restore focus to the trigger. |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | menu / menuitem | Exposes 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). |