Navigation menu
Disclosure panels containing real destinations, with native links and complete installed positioning and focus contracts.
Choose destination disclosure
Use this component when a few clearly named categories reveal links to pages or sections. Use ordinary navigation links when destinations already fit; use search for a large changing set. Application commands belong in Menu, Menubar or Toolbar; related views of one task belong in Tabs.
A navigation disclosure is not an ARIA command menu. The root and popup are named navigation landmarks, with list items, disclosure buttons and native anchors. Preserve familiar link behavior and visible click/keyboard access; hover is an additional path, not the only path.
- Name each navigation scope and use destination labels that predict arrival. A category button opens content; it should not masquerade as a destination.
- Reflect actual current route through NavigationMenuLink active. Open disclosure value and current destination are different states.
- Keep permission and availability policy independent from navigation styling. An expanded panel or successful page response grants no source access or review authority.
Anatomy and dimensions
| Part | Actual shared contract |
|---|---|
| NavigationMenu | Root nav, or div when nested. HTMLElement ref; explicit data-orientation supports shared layout. |
| List / Item | Default ul/li and actual HTMLUListElement/HTMLLIElement public refs. Horizontal list wraps within available width; vertical mode makes a column. |
| Trigger / Icon | Default button with minimum 44px target, 8px × 12px padding and expanded state. Icon is a decorative span; actual HTMLSpanElement public ref. |
| Content | Each item owns content that is moved into the active viewport. Default div; 16px padding, no independent border/shadow. Closed content is hidden. |
| Portal / Positioner | Portal contains collision-aware anchoring to the active trigger. Positioner owns floating position and dropdown stacking. |
| Popup / Viewport | Popup is a named nav surface; width min(320px, viewport−32px), maximum height min(448px, available height), scrollable. Viewport is a relative clipping div inside it, not an absolutely positioned substitute for the popup. |
| Current / focus / alternate modes | Current links are bold with thicker underline. Focus has an independent 2px inset ring. Single border surface; reduced motion and forced-color treatments are supplied. |
Working destinations and retained context
Edit the draft, open Local destinations and follow a real fragment. The actual overview/details content becomes the destination and its surviving heading receives focus after close completion. Related guides are real routes; full route navigation leaves this page-local draft. Layout restoration changes only local presentation.
Opening panels and same-page destinations retain this text. A full route navigation or reload leaves this page-local draft; no save occurs.
Open local navigation group: none. Destination availability does not establish source permission.
Local overview destination
Actual same-page overview content. The retained draft is: English-only local navigation draft.
Local details destination
Actual same-page details content. No remote collection was fetched or reviewed.
'use client';
import { useRef, useState, type MouseEvent } from 'react';
import { Button, DirectionProvider, Input, NavigationMenu, NavigationMenuContent, NavigationMenuIcon, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuPopup, NavigationMenuPortal, NavigationMenuPositioner, NavigationMenuTrigger, NavigationMenuViewport } from '@mobtranslate/ui';
export function NavigationDestinationsExample() {
const [value, setValue] = useState<string | null>(null);
const [vertical, setVertical] = useState(false);
const [rtl, setRtl] = useState(false);
const [draft, setDraft] = useState('English-only local navigation draft');
const overview = useRef<HTMLHeadingElement>(null);
const details = useRef<HTMLHeadingElement>(null);
const arriving = useRef<HTMLElement | null>(null);
const direction = rtl ? 'rtl' : 'ltr';
function arrive(event: MouseEvent<HTMLAnchorElement>, target: HTMLElement | null) {
if (event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && !event.defaultPrevented) arriving.current = target;
}
return <div data-destination-lab className="space-y-4">
<label htmlFor="destination-local-draft" className="block">Local navigation draft</label>
<Input id="destination-local-draft" value={draft} onChange={event => setDraft(event.target.value)} aria-describedby="destination-draft-help" />
<p id="destination-draft-help">Opening panels and same-page destinations retain this text. A full route navigation or reload leaves this page-local draft; no save occurs.</p>
<div className="flex flex-wrap gap-3"><Button type="button" variant="outline" aria-pressed={vertical} onClick={() => { setValue(null); setVertical(current => !current); }}>Use vertical local navigation</Button><Button type="button" variant="outline" aria-pressed={rtl} onClick={() => { setValue(null); setRtl(current => !current); }}>Use RTL local navigation</Button></div>
<DirectionProvider direction={direction}><div dir={direction}>
<NavigationMenu aria-label="Local destination navigation" value={value} onValueChange={setValue} orientation={vertical ? 'vertical' : 'horizontal'} onOpenChangeComplete={open => { if (!open) { const target = arriving.current; arriving.current = null; target?.focus({preventScroll:true}); } }} className={state => state.open ? 'local-destinations-open' : undefined}>
<NavigationMenuList>
<NavigationMenuItem value="local"><NavigationMenuTrigger data-destination-trigger>Local destinations <NavigationMenuIcon /></NavigationMenuTrigger><NavigationMenuContent aria-label="Local destination links">
<NavigationMenuLink href="#navigation-local-overview" closeOnClick onClick={event => arrive(event, overview.current)}>Read local overview</NavigationMenuLink>
<NavigationMenuLink href="#navigation-local-details" closeOnClick onClick={event => arrive(event, details.current)}>Read local details</NavigationMenuLink>
</NavigationMenuContent></NavigationMenuItem>
<NavigationMenuItem value="guides"><NavigationMenuTrigger>Related guides <NavigationMenuIcon /></NavigationMenuTrigger><NavigationMenuContent aria-label="Related guide links">
<NavigationMenuLink href="/components/tabs" closeOnClick>View tabs guide</NavigationMenuLink>
<NavigationMenuLink href="/patterns/navigation" closeOnClick>View navigation patterns</NavigationMenuLink>
</NavigationMenuContent></NavigationMenuItem>
<NavigationMenuItem value="unavailable"><NavigationMenuTrigger disabled>Source collection unavailable</NavigationMenuTrigger><NavigationMenuContent><p>Unexpected unavailable navigation activation.</p></NavigationMenuContent></NavigationMenuItem>
<NavigationMenuItem><NavigationMenuLink href="/components/navigation-menu" active>Current navigation guide</NavigationMenuLink></NavigationMenuItem>
</NavigationMenuList>
<NavigationMenuPortal><NavigationMenuPositioner sideOffset={8} align="start" collisionPadding={8}><NavigationMenuPopup aria-label="Expanded destination navigation" dir={direction}><NavigationMenuViewport /></NavigationMenuPopup></NavigationMenuPositioner></NavigationMenuPortal>
</NavigationMenu>
</div></DirectionProvider>
<p role="status">Open local navigation group: {value ?? 'none'}. Destination availability does not establish source permission.</p>
<div className="space-y-3"><h3 id="navigation-local-overview" ref={overview} tabIndex={-1} className="font-semibold">Local overview destination</h3><p>Actual same-page overview content. The retained draft is: {draft || '(empty)'}.</p><h3 id="navigation-local-details" ref={details} tabIndex={-1} className="font-semibold">Local details destination</h3><p>Actual same-page details content. No remote collection was fetched or reviewed.</p></div>
<Button type="button" variant="outline" onClick={() => { setValue(null); setVertical(false); setRtl(false); }}>Restore local navigation layout</Button>
</div>;
}
Installed API and composition reference
These contracts describe @base-ui-components/react 1.0.0-rc.0 in this workspace, not current upstream 1.x documentation. The installed declarations incorrectly annotate some ul/li/span refs as div; the wrappers expose the actual default DOM ref types and confine that compatibility bridge internally.
| Export / prop | Contract |
|---|---|
| NavigationMenu value / defaultValue / onValueChange | Controlled/uncontrolled open item and event details. null closes. Use unique non-empty string values: this pinned runtime uses truthiness, so 0, false and an empty string do not represent open items. |
| NavigationMenu delay / closeDelay | Installed defaults 50ms / 50ms for hover timing. Click/keyboard access remains available. Hover-open rapid clicks can retain the panel; use Escape/outside/focus departure for explicit dismissal. |
| NavigationMenu orientation | horizontal default; vertical alternative. DOM layout and DirectionProvider must agree with desired direction. Orientation is exposed as data-orientation for layout; the native nav/list omit unsupported aria-orientation. |
| NavigationMenu actionsRef / onOpenChangeComplete | Installed imperative unmount handle and completion callback. The lab uses close completion to focus a surviving same-page destination; a router still owns cross-route arrival policy. |
| NavigationMenuItem value | Unique category identity; explicit values are needed for controlled opening. Nested roots need their own values, labels and interaction checks. |
| NavigationMenuTrigger disabled / nativeButton / native props | Installed button semantics and unavailable state. The workspace dependency patch gates disabled hover and interaction in both CJS and ESM; verify pointer, hover and keyboard enforcement rather than inferring it from opacity. |
| NavigationMenuLink href / active / closeOnClick | Native href; active false default supplies current-page semantics when true. closeOnClick false default; this example explicitly closes after following a link. |
| NavigationMenuPortal / Positioner | Installed container/mounting and side/align/offset/collision contracts. A requested side can flip to fit; inspect actual geometry. |
| NavigationMenuPopup / Viewport / Content | Compose all required parts. Popup has no MenuPopup finalFocus prop; arrival policy belongs to close completion/router integration. |
| NavigationMenuIcon / NavigationMenuArrow / NavigationMenuBackdrop | Trigger icon plus optional installed arrow/backdrop parts. Arrow renders the primitive anchor mark; backdrop participates in the popup composition. Native props, state class/style callbacks, render composition and refs follow the pinned declarations. Styling/stacking are caller-owned; the destination lab needs neither optional part. |
| className / style / render / native props | Styled Base UI parts retain state callbacks and installed composition contracts. Preserve the actual node, roles, handlers and refs; native breadcrumb/pagination parts accept native string class names and do not have a Base UI render prop. |
Keyboard, pointer and focus
- Tab follows the disclosure/link sequence rather than treating these destinations as a command-menu role. Installed arrows additionally move across categories and content links; inspect actual focus order after custom rendering.
- Enter/Space operates a trigger; horizontal ArrowDown enters its panel. The pinned vertical trigger uses physical ArrowRight for entry. DirectionProvider changes composite directional movement; do not assume every vertical entry key is mirrored in RTL.
- Escape closes and restores a surviving opener when appropriate. Leaving the disclosure via focus or outside interaction dismisses without stealing focus from another task.
- Following a same-page link uses its real fragment; the lab restores destination-heading focus on close completion. Native href is retained for modified clicks. A full route needs actual arrival, history and deep-link checks.
- Hover timing and safe pointer traversal should not prevent touch or keyboard access. Hardware touch, platform context menus and spoken announcements remain separately verifiable.
States, adaptive layout and recovery
| Situation | Response |
|---|---|
| Closed / category open | Keep trigger expanded state, controlled value and visible panel aligned. Opening a sibling replaces the active content instead of stacking unrelated panels. |
| Unavailable destination/category | Explain why nearby; suppress unsupported interaction. A native anchor has no disabled behavior: use truthful non-link text or an explicitly guarded alternative. |
| Retained task context | Opening/closing disclosures must not reset entered values. Full navigation requires a deliberate unsaved-draft/persistence policy; this draft is intentionally page-local. |
| Small viewport / long labels | Wrap category labels and bound the popup to collision space; keep the last link reachable by scrolling. A simple drawer or search can suit larger mobile hierarchies better. |
| Portal direction/theme | Pass the intended DOM dir to the portaled surface as well as DirectionProvider. A local theme/container must preserve actual popup colors and naming. |
| Nested composition | Nested roots become divs; retain parent/child labels, complete popup composition and a return path. The one-level lab does not certify arbitrary nesting. |
| Route loading/failure | Preserve useful context and provide actual error/retry/alternative behavior in the router. Disclosure state does not prove that a destination loaded. |
Common failures and migration
- Plain nav buttons with decorative chevrons are not working disclosures. Use the complete primitive composition and verify actual links/content.
- Positioner and Popup are now public parts. Put Viewport inside Popup; the former absolute viewport style was incompatible with this installed floating structure.
- Do not apply command-menu roles to website destinations or replace real href with an inert hash placeholder.
- Default ul/li/span refs are corrected from the installed div annotations. Custom elements must still preserve their actual semantics/ref contract.
- Do not claim a current destination because its category is open. Derive active from actual route/section state.
Verification and release scope
- Follow actual destinations and read actual records. Check native link/button semantics, names, current state, focus, unavailable activation and retained versus requested outcomes. Appearance or callbacks alone are insufficient.
- Test both themes, long labels, 320px reflow, RTL/orientation, zoom/text spacing, reduced motion and forced colors. Device touch and spoken announcements need named platform evidence.
- Run test:destinations, checked-source/token freshness, lint, UI/styleguide typechecks, production build and route/fragment crawl. The suite supplies scoped browser evidence, not whole-library certification.
- The English-only local fixture establishes no source quality, cultural authority, permission, authenticated access, persistent write or remote collection result.