Skip to content
Component installs need the registry setup
VegaStack Design

Navigation Menu

A collection of links for navigating websites — triggers that open one shared panel, plain links styled to match, and any routing library's link through render.

Status
stable
Since
0.3.0
Accessibility pattern
APG disclosure navigation

Last updated

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Navigation Menu before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify navigation-menu

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/navigation-menu

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

Usage

import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
} from "@/components/ui/navigation-menu";

<NavigationMenu>
  <NavigationMenuList>
    <NavigationMenuItem>
      <NavigationMenuTrigger>Item One</NavigationMenuTrigger>
      <NavigationMenuContent>
        <NavigationMenuLink>Link</NavigationMenuLink>
      </NavigationMenuContent>
    </NavigationMenuItem>
  </NavigationMenuList>
</NavigationMenu>;

A navigation menu is a desktop row. Its list does not wrap, and its panel is as wide as its content, clamped to the space beside the screen edge with anything wider clipped. Fitting a phone is the call site's job: size each panel responsively (w-64 sm:w-96, not a fixed w-96), hide secondary triggers below a breakpoint (hidden sm:flex), and below sm move the navigation into a Sheet. The demo above does the first two.

Anatomy

NavigationMenu — data-slot="navigation-menu"
NavigationMenuContent — data-slot="navigation-menu-content"
NavigationMenuIndicator — data-slot="navigation-menu-indicator"
NavigationMenuItem — data-slot="navigation-menu-item"
NavigationMenuLink — data-slot="navigation-menu-link"
NavigationMenuList — data-slot="navigation-menu-list"
NavigationMenuPositioner
NavigationMenuTrigger — data-slot="navigation-menu-trigger"

Examples

Composition

NavigationMenu renders the portal, the positioner, the popup and the viewport itself, so the call site is the nav row alone: a list of items, each holding a trigger and its content, or a plain link. align on NavigationMenu decides which edge of the row the shared panel hangs from.

NavigationMenu
├── NavigationMenuList
│   ├── NavigationMenuItem
│   │   ├── NavigationMenuTrigger
│   │   │   └── NavigationMenuIndicator
│   │   └── NavigationMenuContent
│   │       ├── NavigationMenuLink
│   │       └── NavigationMenuLink
│   └── NavigationMenuItem
│       └── NavigationMenuLink
└── (portal · positioner · popup · viewport — rendered by NavigationMenu)

render on NavigationMenuLink replaces the element it renders, which is how a routing library's link gets the menu's styling, its data-slot and its keyboard behaviour. In a Next.js app that is render={<Link href="/docs" />}. Adding navigationMenuTriggerStyle() makes a top-level link sit flush with the triggers beside it.

RTL

Wrap the subtree in DirectionProvider direction="rtl" so Base UI resolves the logical sides, set align="end" so the panel hangs from the right edge of the nav row, and put dir on NavigationMenuContent — the panel portals out of the dir="rtl" subtree, so the attribute has to travel with it.

API Reference

NavigationMenu adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

NavigationMenuList adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

NavigationMenuItem adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

NavigationMenuTrigger adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

NavigationMenuContent adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

NavigationMenuLink adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

NavigationMenuIndicator adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

NavigationMenuPositioner adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

navigationMenuTriggerStyle() is the trigger's class recipe, exported so a plain NavigationMenuLink in the nav row can wear it.

Accessibility

  • Renders Base UI's Navigation Menu: the row is a nav, each trigger is a button with aria-expanded and aria-controls, and the panel is a labelled region. Esc closes the open panel and returns focus to its trigger.
  • Arrow keys move between the triggers in the row and into the open panel, Home and End jump to the ends, and Tab leaves the menu entirely — the row is one tab stop, not one per link.
  • A disabled trigger keeps its pointer events (FRM-4), so a tooltip can explain why it is unavailable; it is still aria-disabled and still cannot be activated.
  • The panel portals out of the nav's subtree, so NavigationMenuPositioner re-applies the theme scope on the positioner inside the portal (OVL-13) — a dark or scoped surface survives the portal.
  • Focus is the global 2px :focus-visible outline from base.css. Upstream suppressed the outline for every link inside the panel; that suppression is removed here (FOC-1, FOC-6), because the panel is where this menu's keyboard navigation actually lives.
ContractStates tested
Behaviourdefault, open, closed, active, disabled
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, disabled
Visualdefault, hover, open, active, disabled, focus-visible

Do / Don't

Do
Use a Navigation Menu for site or product navigation whose sections need a panel of links, and give each panel real anchors so it works without JavaScript.
Don't
Use it for in-app commands — that is a DropdownMenu — or for switching between views of one page, which is Tabs.

Deviations

Upstream's file plus packages/ui/upstream/patches/navigation-menu.patch. Every hunk:

  • FOC-1, FOC-6 — outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 goes from navigationMenuTriggerStyle and from NavigationMenuLink, and the pair **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none goes from NavigationMenuContent — it suppressed the outline for every link inside the popup, which is where this menu's keyboard navigation actually lives.
  • FRM-4 — disabled:pointer-events-none is dropped from the trigger recipe; the opacity stays.
  • OVL-13 — NavigationMenuPositioner reads useInternalThemeScope() and attaches it to the Positioner INSIDE NavigationMenuPrimitive.Portal — the same host shape popover, select, combobox and tooltip carry. tooling/verify-portal-theme-scope.mjs inventories this host.
  • API-16 — the file gains 'use client'. Upstream ships none because it calls no hook of its own; OVL-13's useInternalThemeScope() is a hook, and this file is the lowest leaf that needs the boundary.
  • BRD-1 — The shared viewport popup and the viewport-less (viewport={false}) NavigationMenuContent draw a real 1px border border-border instead of upstream's ring-1 ring-foreground/10 box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves.
  • DOC-2 — import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header the three-copy model needs; no decision of its own.

On this page