registry:ui · ARIA
navigation menu — a bar you WALK, with panels that move
The site-nav pattern as an independent thin coordinator: ←/→ walk the top-level triggers (one tab stop, on the current section), click opens a panel, Escape closes and hands focus back. Panels ride the Popover primitive's laws — native popover=auto light dismiss, CSS anchor positioning, and the WAAPI surface-motion entry/exit — through a DECLARATIVE popovertarget wire, and open state mirrors the native toggle seam only, so aria-expanded never lies. Panels carry REAL LINKS — navigation moves you; actions belong to dropdown-menu. Duplicated deliberately: the three menu roots stay independent registry items, no hidden coupling.
$npx jixoai-ui add navigation-menu
https://ui.jixoai.com/r/navigation-menu.json
overview
Overview
The composition: an Item that owns the one id, a declarative wire, a panel that is a real popover, and a bar that owns state and behavior — never membership order.
The family composes in four parts. NavigationMenuItem is the pairing
unit — the Popover primitive's anchor law made a part: a span that owns the ONE
id (explicit or $props.id(), mount-stable by contract) and carries anchor-name for CSS Anchor Positioning. NavigationMenuTrigger is a real button aria-haspopup whose open/close rides the DECLARATIVE popovertarget wire — the primitive's zero-listener path; the invoker
association exempts the trigger from light dismiss by construction, no click-order
race. NavigationMenuPanel is a native popover="auto" surface: light dismiss, Escape, one-at-a-time and the top layer are the browser's;
CSS anchor positioning places it (position-area: bottom span-right —
the family's own law is LEFT edges aligned under the item, a measured span-right
semantics — with flip-block/flip-inline try-fallbacks and anchors-visible hiding), and the shared WAAPI surface-motion kernel
animates entry/exit as one @property number. Bare NavigationMenuLinks
compose directly in the bar — there is no popover to pair.
Two state machines are the bar's own. The roving walk: ←/→ moves between triggers
over a SINGLE tab stop (a nav bar is walked, not hunted) — every trigger renders
tabbable until the bar trims to one after mount, the current section's trigger
first; while a panel is open, walking swaps panels without a close bounce (the
menubar glide, through imperative handles resolved from aria-controls).
The panel registry: first-wins on BOTH keys (the panel id and the sanitized anchor
name — foo/bar and foo?bar collide on the anchor while
carrying distinct DOM ids), registered at init (sync, SSR-executed), unregistered
identity-guarded, so a conditionally removed panel leaves no ghost handle. The walk
scope never leaks: triggers inside [popover] panels and nested plain
navs are excluded from THIS bar's walk. Click-open only (Owner ruling
2026-08-25): the hover path, its intent delay and its pointer corridor are retired.
The duplication contract, stated plainly: panels carry REAL LINKS — navigation-menu
moves you through a site; actions belong to dropdown-menu. The three
menu roots (dropdown-menu, menubar, navigation-menu) are DELIBERATE duplicates:
registry items stay independent, no hidden coupling — each keeps its own parts,
context keys and sheets, and the only sharing is fleet-LAW level (the
density-adoption spec pins all the menu roots under one honest-opinion law: a root
with no density opinion stamps NOTHING and its subtree rides the ambient css
scope). The current section is aria-current on a trigger
(true) or a link (page) — the page's own truth, passed
in; the menu never guesses. Per-axis below; the shared grammar lives on the universal props page.
usage
Usage
Keep navigation links in panels; actions belong in dropdown-menu. Grouped entries pair an Item + Trigger + Panel; direct destinations are bare links.
<script lang="ts">
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuTrigger,
NavigationMenuPanel,
NavigationMenuLink,
} from '@ui/navigation-menu/index';
</script>
<NavigationMenu label="site">
<NavigationMenuItem>
<NavigationMenuTrigger>Product</NavigationMenuTrigger>
<NavigationMenuPanel>
<!-- real links — navigation-menu MOVES you; actions live in dropdown-menu -->
<NavigationMenuLink href="/x" current>Overview</NavigationMenuLink>
…any markup — mega grids compose inside the panel…
</NavigationMenuPanel>
</NavigationMenuItem>
<NavigationMenuLink href="/docs">docs</NavigationMenuLink>
</NavigationMenu>demo
Indicator — the optional sliding active
NavigationMenuIndicator is the indicator technology as an OPTIONAL part (render it as a child of the bar; omit it and nothing changes). TWO motion laws: motion=navigation (default) stamps a view-transition-name — page-level View Transitions morph the indicator across documents (the app owns the transition wiring; the name is inert when no transition runs, and same-document moves still animate) — and motion=waapi is the pure Web Animations path for apps that never use View Transitions: no name, no cost. The engine measures the bar's current entry (aria-current, DOM-delegated — a MutationObserver catches route swaps, including late-mounted entries; ResizeObserver and fonts.ready re-fit quietly) and slides the hug-box between entries; first placement, resizes and reduced-motion JUMP. Entries inside an open panel never steal the bar indicator, and a nested plain nav owns its own current truth.
a11y
Accessibility
The bar exposes one roving tab stop and keeps every destination a real link; the indicator is decoration (aria-hidden, no tab stop — the part adds nothing focusable).
Keyboard
| Key | Action |
|---|---|
| Tab | Reaches the bar as ONE tab stop (the roving tabindex — the current section's trigger first, else the first entry) |
| ← → | Move between top-level triggers and wrap; an open panel glides to the next trigger's panel without a close bounce |
| Enter / Space | Open the focused trigger panel (a real button; the declarative popovertarget wire carries click too) |
| Escape | Close the panel and restore focus to its trigger — the explicit preventDefault-then-hide keeps focus returning over a closed panel, never a still-open one |
ARIA
| Attribute | Value | Description |
|---|---|---|
| aria-current | true (trigger) / page (link) | Marks the current section — the page's own truth, passed in; the menu never guesses |
| aria-expanded | boolean | Mirrors ONLY the panel's native toggle seam (:popover-open read live at fire time) — it cannot lie |
| aria-controls | panel id | Derived from the Item's one id; also the glide's trigger→panel resolution |
| aria-label | label prop | The nav landmark's accessible name (default "site") |
| aria-haspopup | "true" | Every trigger; also the walk-scope selector — panel content and nested navs never leak into the bar's walk |
| indicator | aria-hidden; not focusable | The sliding part is a bare decorated span: aria-hidden, no tabindex attribute, therefore no tab stop and no semantics — the plain-span truth both reviewers measured |
axes
The eight axes
Resolved on the nav root through NavigationMenuDefaults (the single audited contract: the eight universal slots + variant + inset). The universal density slot carries NO own (the honest-opinion law); the family owns elevation level2 — the menu rung. Two axes are consumed one promotion away: the PANEL reads the radius/shape carriers through the DOM inheritance a top-layer promotion keeps.
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | ambient scope | SUPPLY-ONLY for the entries — the §11 stamp sets the nav root's font-size (measured 16 → 14px) but the entries never inherit it: every entry carries its own type voice (--jx-text from the density kernel, uppercase, tracked) which beats inheritance by declaration. The em-relative letterspacing (0.08em) scales with --jx-text, not the stamp. The one reached surface: the bar's own anonymous flow. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | CONSUMED THROUGH THE PANEL — the carrier is read one promotion away: popover.css declares corner-shape: var(--jx-shape-effective, round) on .jx-pop, and the nav root stamps the carrier; the top layer moves the PAINT, never the DOM, so the declaration site's value rides along. The bar itself paints no corners (the entries are flat ink). |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | CONSUMED THROUGH THE PANEL — the family always stamps --jx-radius-consumed on the root (explicit: radius-effective × the §14 factor; auto: the §3 concentric calc minus the inset), and .jx-pop's border-radius reads exactly that var (popover.css :37-47). Measured: the ambient page resolves the calc to the square 0px the sheet declares; radius='large' on the nav and the OPEN panel's computed radius jumps to the large rung. Number unit: px. |
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'auto' | CONSUMED through the kernel lanes, scoped by the honest-opinion law — the entries' hit size, gap, inline padding and type are plain var(--jx-hit/--jx-gap/--jx-inset/--jx-text/--jx-line) reads (raw var() strings: substitution at the consuming element), so the values follow the nearest [data-density] scope. No opinion → the nav stamps NOTHING (no attribute — pinned by density-adoption-menus) and rides the ambient css scope (measured idle bar: 40px hit, 13px type, 12px gap/padding, no attribute anywhere); an explicit rung stamps data-density on the root AND carries the opinion to triggers/panels through the bar context, re-scoping the whole bar (measured sm: 32px hit, 12px type, 8px gap/padding, on both root and trigger). Number unit: coefficient — the carrier lands on the root (--jx-density-coefficient: 1.5, measured) but the kernel lanes are computed at their DECLARING scope, so a bare coefficient re-rungs nothing (measured: the entries hold 40px/13px); it moves only consumers that read the carrier at or below the stamp. |
| 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/navigation-menu/ and popover.css). The bar's ink ladder is the theme token law (idle --jx-muted-foreground → current --jx-primary → open/hover --jx-foreground), not the color axis. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | CONSUMED AS A SPLIT — theme='dark' stamps .dark on the nav root and the family's two surfaces answer DIFFERENTLY (the emission-form mixed law, measured): the PANEL flips live — .jx-pop reads the raw --popover/--popover-foreground tokens, which .dark re-declares for its subtree, and the top-layer promotion keeps DOM inheritance so the floating surface re-schemes with it — while the BAR's ink freezes — the atoms read the typed --jx-foreground/--jx-primary/--jx-muted-foreground aliases, declared once at :root by the stylex theme (no .dark re-declaration), so a scoped dark stamp computes them at :root and the entries keep the ambient light values. The indicator's tonal fill sits on the flipped side (--jx-tonal is re-declared per theme scope in the sheet). Cross-reference: the scheme-observer family (native-scroll-area) flips EVERYTHING by resolving the scope in family state; this family flips exactly what the css cascade reaches. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'level2' · Own default, not ambient | CONSUMED THROUGH THE PANEL — the family's OWN (level2, 3dp: the menu rung shared with menubar and dropdown-menu) resolves even when the prop is auto, and the §7 pair (shadow recipe + the PAIRED ladder-rung surface) plus the solid-fill bridge stamp on the nav root; .jx-surface-body's fill and ::after shadow read them through the promotion-kept inheritance. Measured: level2 vs elevation='level3' changes the OPEN panel's surface fill. The NAV itself paints nothing — the bar is chrome, never elevated. 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 real motion laws are platform and kernel: the entries' 150ms ease-out color transitions (the theme sheet's own constant — the recorded --jx-motion-fast promotion candidate), the panel's WAAPI surface motion (one @property number, every visible property a CSS formula), and LAW #14's reduced-motion contract — the indicator JUMPS instead of sliding and the kernel's prefers-reduced-motion guard short-circuits to direct writes. Number unit: coefficient. |
| Token | Default | Source |
|---|---|---|
| --jx-hit / --jx-gap / --jx-inset | density scale ([data-density] scopes) | density |
| --jx-text / --jx-line | density scale × the coefficient | density |
| --jx-pop-pad / --jx-pop-pad-inline | 12px 14px / 14px | component |
| --jx-radius-consumed | stamped by the nav root, read by .jx-pop | component |
| --jx-surface-solid-fill | the level2 ladder rung (own) | structural |
| --motion-150 / --motion-ease-out | theme constants | structural |
navigation-menu · the measured axes
<NavigationMenu label="axes" density="sm">…</NavigationMenu>
<NavigationMenu label="axes" radius="large" elevation="level3">…</NavigationMenu>
<!-- theme: a split, not a switch — the PANEL re-schemes (raw --popover
reads under the scoped .dark), the BAR ink freezes (typed aliases
declared once at :root) -->
<NavigationMenu label="dark island" theme="dark">…</NavigationMenu><script lang="ts">
import NavigationMenu from '@ui/navigation-menu.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the bar rides the ambient scope stamped 'sm'; from md
up the bar states its own lg opinion (the honest-opinion stamp:
data-density lands only because the lane resolved one) -->
<NavigationMenu label="responsive bar" density={query({ md: 'lg' }, 'sm')}>
…entries…
</NavigationMenu>api
API
Five parts + the Defaults contract (NavigationMenuDefaults: the eight universal slots, variant's literal slot with own 'auto', the indicator's open-domain inset slot with own 0, elevation's own level2). The panel declares NO rest spread — its popover/id/toggle wiring is load-bearing family law, the sweep's declared exception.
NavigationMenu
| Property | Type | Default | Description |
|---|---|---|---|
| label | string | 'site' | The nav landmark's accessible name. |
| variant | 'solid' | 'acrylic' | 'auto' | 'auto' · Own default, not ambient | The floating-surface paint for every panel in the bar (the .jx-surface variants: solid, the 72%-alpha blur acrylic, auto = acrylic unless reduced transparency). Literal slot — own 'auto', ambient when an axis opens. |
| inset | number | 0 | NavigationMenuIndicator hug inset per edge (px). Open-domain literal slot — own 0; breathing inside the entry is a declared decision, never a guessed constant. |
| class / style | string | '' | Root passthrough — the §11 carriers, the radius/elevation consumption and the caller's style compose on the nav root. |
| children * | Snippet | — | The bar's entries: Item-wrapped trigger+panel pairings and bare links, in any order — the bar owns state and behavior, never membership order. |
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) |
The parts
| Property | Type | Default | Description |
|---|---|---|---|
| NavigationMenuItem | id?: string | $props.id() | The pairing unit — owns the ONE id (mount-stable; changing it is dev-warned caller error) and the anchor-name span. Trigger/Panel derive `${id}-trigger` / `${id}-panel`. |
| NavigationMenuTrigger | current?: boolean | false | Real button aria-haspopup; the declarative popovertarget wire; aria-current="true" + the brand color when current; the roving tabindex follows the bar's tab stop. |
| NavigationMenuPanel | children | — | popover="auto" + CSS anchor positioning + WAAPI surface motion; registers {show, hide} at init under the derived panel id (first-wins); NO rest spread — the popover wiring is load-bearing. |
| NavigationMenuLink | current?, href, child? | false | The bare in-bar link (no Item wrapper); aria-current="page" + brand color when current; child({props}) replaces the element kind — keep {...props} for the link semantics and the data-jx-navmenu-link marker. |
| NavigationMenuIndicator | motion?, name?, duration?, easing?, inset? | 'navigation' · 'jx-nav-indicator' · 240ms | The optional sliding active: hug-box measured geometry, MutationObserver + ResizeObserver + fonts.ready re-fit, B-1/B-3 interrupt laws; inset resolves through the family Defaults (own 0). |