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

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.

install — navigation-menu

$npx jixoai-ui add navigation-menu

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

roving walk click open panels = links deliberate duplicate

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.

basic composition
<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>

navigation menu · the two entry shapes

navigation-menu-variants-demo.svelte svelte
<script lang="ts">
  import {
    NavigationMenu,
    NavigationMenuItem,
    NavigationMenuTrigger,
    NavigationMenuPanel,
    NavigationMenuLink,
  } from '@ui/navigation-menu/index';
</script>

<div class="grid gap-4 sm:grid-cols-2">
  <div class="border border-border p-4">
    <NavigationMenu label="grouped">
      <NavigationMenuItem>
        <NavigationMenuTrigger>Product</NavigationMenuTrigger>
        <NavigationMenuPanel>
          <NavigationMenuLink href="/docs">Overview</NavigationMenuLink>
        </NavigationMenuPanel>
      </NavigationMenuItem>
    </NavigationMenu>
  </div>
  <div class="border border-border p-4">
    <NavigationMenu label="direct">
      <NavigationMenuLink href="/docs" current>Docs</NavigationMenuLink>
    </NavigationMenu>
  </div>
</div>

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.

navigation menu · indicator

motion="navigation" — the page-level default
motion="waapi" — no View Transitions, no name
navigation-menu-indicator.svelte svelte
<!--
  jixoai NavigationMenuIndicator
  (registry/files/ui/navigation-menu/navigation-menu-indicator.svelte,
  2026-09-01).
  The OPTIONAL sliding-active part of the navigation-menu family — the
  indicator technology sunk OUT of the site chrome (TerminalHeader kept
  a private copy because the family offered no surface to hang it on).
  Render it as a child of <NavigationMenu>; it measures the bar's
  current entry — [data-jx-navmenu-link][aria-current="page"] or
  [data-jx-navmenu-trigger][aria-current="true"], NEVER an entry inside
  an open [popover] panel (mega links carry their own current truth)
  and never one owned by a NESTED plain <nav> (Codex P2, 2026-09-02:
  an inline panel's nav is its own current truth — see ownedEntry) —
  and slides to it.

  TWO motion laws (Owner ruling, 2026-09-01 — the offering, not a
  default):

    motion="navigation" (default)  for PAGE-LEVEL nav under View
      Transitions: the indicator carries a view-transition-name, so a
      same-named element on the next document morphs across the
      navigation (the app owns the transition wiring — SvelteKit
      onNavigate startViewTransition, or cross-document VT). A stamped
      name is INERT when no transition ever runs. Same-document entry
      moves still animate (WAAPI below) — the two laws compose.

    motion="waapi"                  for apps with NO View Transitions:
      pure Web Animations API sliding, no view-transition-name — the
      nav never pays for a name it does not use.

  The WAAPI engine (both modes): one element, measured geometry
  (offsetLeft/offsetTop — layout coords, scroll/RTL-safe), animated
  between the previous and next box on entry changes; the FIRST
  placement, resize/font-driven remeasures and prefers-reduced-motion
  JUMP instead of animating. Two interrupt laws (2026-09-02): a
  mid-flight slide is re-anchored from its CURRENT computed frame
  (never the stale previous target), and a RUNNING View Transition
  suppresses the overlay entirely (the morph carries the motion — see
  the B-1 note in measure()). Repaint triggers are DOM-delegated (the
  part owns no nav data): an aria-current+childList MutationObserver
  (late-mounted current entries get their settling measure), a
  ResizeObserver on the bar, and fonts.ready. jsdom (no WAAPI, no RO)
  degrades to direct style writes — everything stays truthful.

  The part stamps position:relative on its bar (idempotent) so the
  entries' offsetParent and the indicator's containing block agree.
  Default paint: the restrained tonal fill (tabs-pill dialect,
  ink-inverting through tokens); the class seam carries site chrome
  (e.g. a backdrop-filter brightness pill over a dark bezel).
-->
<script lang="ts">
  import { onMount } from 'svelte';
  import { cn } from '$lib/utils';
  import { NavigationMenuDefaults } from './navigation-menu-defaults.svelte';
  import { navMenuStyles } from './navigation-menu.stylex';

  interface Props {
    /** the motion law: 'navigation' stamps a view-transition-name for
        page-level morphs under View Transitions (inert without them);
        'waapi' is the pure Web Animations path — no name, no cost */
    motion?: 'navigation' | 'waapi';
    /** the view-transition-name under motion="navigation"
        (one page-level indicator per name) */
    name?: string;
    /** slide duration in ms (reduced-motion ignores it) */
    duration?: number;
    /** the slide's timing curve */
    easing?: string;
    /** hug inset per edge, px — DEFAULT 0: the indicator spans the
        measured entry exactly (it IS the entry's active background).
        Breathing inside the entry is a declared decision, never a
        guessed constant — a consumer states the number it designed */
    inset?: number;
    class?: string;
  }

  let {
    motion = 'navigation',
    name = 'jx-nav-indicator',
    duration = 240,
    easing = 'cubic-bezier(0.2, 0.8, 0.2, 1)',
    inset,
    class: className = '',
  }: Props = $props();

  // the payload's own join (the separator serialize law): plain strings
  // pass through whole; dev objects contribute their string members ($$css dropped).
  const cx = (
    ...styles: ({ readonly [key: string]: string | object } | undefined | string)[]
  ): string =>
    styles
      .filter(Boolean)
      .map((style) =>
        typeof style === 'string'
          ? style
          : Object.entries(style ?? {}).flatMap(([key, value]) =>
              key !== '$$css' && typeof value === 'string' ? [value] : [],
            ).join(' '),
      )
      .join(' ');

  // THE DEFAULTS READ POINT (context-defaults-economy 3.3): one line —
  // inset resolves through the family contract (the literal slot: own
  // 0 declared in NavigationMenuDefaults, auditable in one place; the
  // sheet size slot's number sibling)
  const d = $derived(NavigationMenuDefaults.resolve({ inset }));

  let indEl = $state<HTMLSpanElement | null>(null);

  /** the bar this part renders in: the nearest nav — or, when composed
   *  into chrome AROUND the family (TerminalHeader's pill box hosts the
   *  part beside the consumer's nav), the immediate parent box */
  const barEl = (): HTMLElement | null => indEl?.closest('nav') ?? indEl?.parentElement ?? null;

  /** the current DIRECT entry of THIS bar — mega-panel links (inside a
   *  [popover]) carry their own current truth and never steal the bar's
   *  indicator (the TerminalHeader law, verbatim) */
  function currentEntry(): HTMLElement | null {
    const bar = barEl();
    if (!bar) return null;
    return (
      [...bar.querySelectorAll<HTMLElement>(
        '[data-jx-navmenu-link][aria-current="page"], [data-jx-navmenu-trigger][aria-current="true"]',
      )].find((el) => el.closest('[popover]') === null && ownedEntry(el, bar)) ?? null
    );
  }

  /** OWNED-nav scoping (Codex P2, 2026-09-02): the [popover] exclusion
   *  alone let a nested plain <nav> inside an INLINE panel leak its
   *  aria-current links to the outer bar. The family root's walk scope
   *  is nav-identity (navigation-menu.svelte navTriggers:
   *  closest('nav') === navEl); this bar may also be a CHROME BOX
   *  (the TerminalHeader pill — the consumer nav lands inside it), so
   *  ownership is the shape-aware form: the entry's nearest nav must be
   *  the bar's SINGLE nav layer — the bar itself, or a nav with no
   *  further nav between it and the bar. A deeper chain means an inner
   *  nav owns that link's current truth (nested panels, footers). */
  function ownedEntry(el: HTMLElement, bar: HTMLElement): boolean {
    const nav = el.closest('nav');
    if (nav === null || nav === bar) return true;
    if (!bar.contains(nav)) return false;
    const outer = nav.parentElement?.closest('nav') ?? null;
    return outer === null || !bar.contains(outer);
  }

  type Geo = { x: number; y: number; w: number; h: number };
  let prev: Geo | null = null;
  let first = true;
  /** the in-flight WAAPI slide (B-3, 2026-09-02): kept so an interrupt
   *  can cancel it — its fill would keep repainting the stale target */
  let running: Pick<Animation, 'cancel'> | null = null;

  /** the mid-flight frame off the computed style: the live 2D matrix
   *  (tx/ty) + the animated box. Engines resolving no matrix (jsdom
   *  echoes the authored translate, never a matrix) yield null — the
   *  caller stands on the previous target */
  function snapshotFlight(ind: HTMLElement): Geo | null {
    const cs = getComputedStyle(ind);
    const m = /^matrix\(([^)]*)\)$/.exec(cs.transform);
    const w = parseFloat(cs.width);
    const h = parseFloat(cs.height);
    if (!m || !Number.isFinite(w) || !Number.isFinite(h)) return null;
    const t = m[1].split(',').map((v) => Number(v.trim()));
    if (t.length < 6 || t.some((v) => !Number.isFinite(v))) return null;
    return { x: t[4], y: t[5], w, h };
  }

  function measure(animate: boolean) {
    const bar = barEl();
    const ind = indEl;
    if (!bar || !ind) return;
    const entry = currentEntry();
    if (!entry) {
      running?.cancel();
      running = null;
      ind.style.opacity = '0';
      prev = null;
      return;
    }
    // clamped at 0: a degenerate box (jsdom's zero layout) still writes
    // a legal style instead of a dropped negative one
    const next: Geo = {
      x: entry.offsetLeft + d.inset,
      y: entry.offsetTop + d.inset,
      w: Math.max(0, entry.offsetWidth - d.inset * 2),
      h: Math.max(0, entry.offsetHeight - d.inset * 2),
    };
    const reduce =
      typeof matchMedia === 'function' &&
      matchMedia('(prefers-reduced-motion: reduce)').matches;
    // B-3 (2026-09-02): a fast second flip interrupts the slide — the
    // honest `from` is the flight's CURRENT frame, not the previous
    // target (jump-to-target-then-slide-back reads as a glitch).
    // Snapshot BEFORE cancel (a cancelled animation stops contributing;
    // computed style falls back to the stale underlying target) and
    // BEFORE the restyle below (after it, computed reads the NEW box)
    let from: Geo | null = prev;
    if (running) {
      from = snapshotFlight(ind) ?? prev;
      running.cancel();
      running = null;
    }
    // B-1 (2026-09-02; probe .agents/scripts/probe-vt-waapi.mjs, real
    // Chromium): under motion="navigation" SvelteKit's onNavigate flips
    // aria-current INSIDE the VT updateCallback, so the MO below fires
    // in that window (log: updateCallback-start → mo-fired →
    // updateCallback-end; document.activeViewTransition non-null there)
    // and capture-new would snapshot the overlay's t≈0 frame — the
    // morph degenerates to a static indicator. While a VT runs, SKIP
    // the overlay: the style writes below land the NEW box before the
    // capture and the VT morph itself carries the motion (jsdom has no
    // activeViewTransition — the gate is inert there)
    const inVT =
      (document as Document & { activeViewTransition?: unknown }).activeViewTransition != null;
    // the style lands immediately; a legal move paints an animation OVER
    // it — the element is never left mid-flight if WAAPI is missing
    ind.style.opacity = '1';
    ind.style.width = `${next.w}px`;
    ind.style.height = `${next.h}px`;
    ind.style.transform = `translate(${next.x}px, ${next.y}px)`;
    if (animate && !first && !reduce && !inVT && from && typeof ind.animate === 'function') {
      running = ind.animate(
        [
          {
            transform: `translate(${from.x}px, ${from.y}px)`,
            width: `${from.w}px`,
            height: `${from.h}px`,
          },
          {
            transform: `translate(${next.x}px, ${next.y}px)`,
            width: `${next.w}px`,
            height: `${next.h}px`,
          },
        ],
        { duration, easing },
      );
    }
    prev = next;
    first = false;
  }

  // stamp the bar as the measuring box (idempotent — a bar already
  // positioned by its chrome keeps its own box)
  $effect(() => {
    const bar = barEl();
    if (bar && getComputedStyle(bar).position === 'static') {
      bar.style.position = 'relative';
    }
    measure(false);
  });

  // DOM-delegated repaint triggers: route swaps flip aria-current; box
  // resizes and late font loads shift geometry — all remeasure QUIETLY.
  // childList too (B-6, 2026-09-02): a current entry mounted AFTER the
  // bar measured (hydration order) carries its aria-current ready-made —
  // attributeFilter never fires for a brand-new node, so the insertion
  // itself is the settling measure. The childList arm is RELEVANCE-
  // FILTERED (CR-1 P3-4, 2026-09-02): the nav subtree mutates for
  // reasons that never move the current entry (badges, labels), and an
  // unfiltered observe cancelled the in-flight animation on every one
  // of them — only a mutation that touches the current entry's box
  // (its own insertion, or an insertion inside it) may remeasure
  onMount(() => {
    const bar = barEl();
    if (!bar) return;
    const touchesCurrent = (muts: MutationRecord[]): boolean =>
      muts.some((m) => {
        if (m.type === 'attributes') return true; // an aria-current flip
        const current = currentEntry();
        if (current && (m.target === current || current.contains(m.target as Node))) return true;
        return [...m.addedNodes].some(
          (n) => n instanceof HTMLElement && (n.hasAttribute('aria-current') || !!n.querySelector('[aria-current]')),
        );
      });
    const mo = new MutationObserver((muts) => {
      if (touchesCurrent(muts)) measure(true);
    });
    mo.observe(bar, { subtree: true, childList: true, attributeFilter: ['aria-current'] });
    const ro =
      typeof ResizeObserver === 'undefined'
        ? null
        : new ResizeObserver(() => measure(false));
    ro?.observe(bar);
    document.fonts?.ready.then(() => measure(false)).catch(() => {});
    return () => {
      mo.disconnect();
      ro?.disconnect();
    };
  });
</script>

<span
  bind:this={indEl}
  data-jx-navmenu-ind=""
  data-motion={motion}
  aria-hidden="true"
  class={cn(cx(navMenuStyles.indicator), className)}
  style={motion === 'navigation' ? `view-transition-name: ${name};` : undefined}
></span>

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

KeyAction
TabReaches 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 / SpaceOpen the focused trigger panel (a real button; the declarative popovertarget wire carries click too)
EscapeClose 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

AttributeValueDescription
aria-currenttrue (trigger) / page (link)Marks the current section — the page's own truth, passed in; the menu never guesses
aria-expandedbooleanMirrors ONLY the panel's native toggle seam (:popover-open read live at fire time) — it cannot lie
aria-controlspanel idDerived from the Item's one id; also the glide's trigger→panel resolution
aria-labellabel propThe 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
indicatoraria-hidden; not focusableThe 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.

PropertyTypeDefaultDescription
size 'small' | 'medium' | 'large' | 'auto' | numberambient scopeSUPPLY-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 ambientCONSUMED 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.
TokenDefaultSource
--jx-hit / --jx-gap / --jx-insetdensity scale ([data-density] scopes)density
--jx-text / --jx-linedensity scale × the coefficientdensity
--jx-pop-pad / --jx-pop-pad-inline12px 14px / 14pxcomponent
--jx-radius-consumedstamped by the nav root, read by .jx-popcomponent
--jx-surface-solid-fillthe level2 ladder rung (own)structural
--motion-150 / --motion-ease-outtheme constantsstructural

navigation-menu · the measured axes

ambient (all auto)
density="sm" — the opinionated stamp
size=14 — the root stamp vs the entries' own type
density={1.5} — the coefficient carrier
theme="dark" — the split (open the panel)
radius="large" — read one promotion away
elevation="level3" — the panel surface
navigation-menu-axes.svelte svelte
<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>
one real query() case
<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>

navigation-menu · query()

navigation-menu-query-demo.svelte svelte
<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

PropertyTypeDefaultDescription
label string'site'The nav landmark's accessible name.
variant 'solid' | 'acrylic' | 'auto''auto' · Own default, not ambientThe 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 number0NavigationMenuIndicator 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

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)

The parts

PropertyTypeDefaultDescription
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?: booleanfalseReal 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?falseThe 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' · 240msThe 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).
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h