Skip to content
Primitiv home
Framework
Consumption mode

Navigation Menu

stableSource Figma

The desktop dropdown site nav — a transparent bar of top-level entries, each either a plain link or a trigger that discloses a panel, with one panel open at a time. Panels project into a shared Viewport so the open one morphs into the next, and an Indicator (arrow or underline) tracks the open trigger. Implements the ARIA APG Disclosure Navigation Menu pattern, not a menubar: keyboard, hover-intent and focus are owned by the headless primitive.

Playground

import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport } from "@/components/ui/navigation-menu";import { ChevronDown } from "@primitiv-ui/icons";
<NavigationMenu size="md" aria-label="Docs">  <NavigationMenuList>    <NavigationMenuItem value="concepts">      <NavigationMenuTrigger>        <NavigationMenuTriggerLabel>Concepts</NavigationMenuTriggerLabel>        <NavigationMenuTriggerIcon><ChevronDown aria-hidden="true" /></NavigationMenuTriggerIcon>      </NavigationMenuTrigger>      <NavigationMenuContent forceMount>        <NavigationMenuLink placement="panel" href="#">          <NavigationMenuLinkText>            <NavigationMenuLinkTitle>Tokens</NavigationMenuLinkTitle>            <NavigationMenuLinkDescription>The three-tier token architecture</NavigationMenuLinkDescription>          </NavigationMenuLinkText>        </NavigationMenuLink>        {/* ...more rows */}      </NavigationMenuContent>    </NavigationMenuItem>
    {/* A value-less Item is a plain link, not a disclosure. */}    <NavigationMenuItem>      <NavigationMenuLink href="#" active>Changelog</NavigationMenuLink>    </NavigationMenuItem>  </NavigationMenuList>
  <NavigationMenuIndicator forceMount />  <NavigationMenuViewport forceMount /></NavigationMenu>

Density is set by a data-density ancestor — the Context system, not a NavigationMenu prop.

Installation

npx primitiv add navigation-menu

Import

import { NavigationMenu } from "@/components/ui/navigation-menu";

Copied into your project as .primitiv-navigation-menu — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<NavigationMenu>  <NavigationMenuList>    <NavigationMenuItem value="concepts">      <NavigationMenuTrigger>        <NavigationMenuTriggerLabel />        <NavigationMenuTriggerIcon />      </NavigationMenuTrigger>      <NavigationMenuContent>        <NavigationMenuLink placement="panel">          <NavigationMenuLinkLeading />   {/* optional */}          <NavigationMenuLinkText>            <NavigationMenuLinkTitle />            <NavigationMenuLinkDescription />  {/* optional */}          </NavigationMenuLinkText>          <NavigationMenuLinkTrailing />  {/* optional */}        </NavigationMenuLink>      </NavigationMenuContent>    </NavigationMenuItem>    <NavigationMenuItem>      <NavigationMenuLink />              {/* a plain bar link */}    </NavigationMenuItem>  </NavigationMenuList>  <NavigationMenuIndicator />  <NavigationMenuViewport /></NavigationMenu>

Props

NavigationMenu.Root

Extends HTMLElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
closeDelaynumber150headlessMilliseconds the open panel survives after the pointer leaves the <nav>, so a pointer that clips the edge of a panel on its way back doesn't dismiss it. Returning to the nav within the window cancels the close.
defaultValuestring—headlessValue of the entry whose panel is open on first render. Omit (or pass "") to start with everything closed.
delayDurationnumber200headlessMilliseconds a trigger must be hovered before its panel opens — the hover-intent delay that stops a panel flashing open as the pointer crosses the nav on its way somewhere else. 0 opens immediately. Ignored once a panel is already open: moving between triggers always switches without delay. Ignored entirely when NavigationMenuRootProps.openOnHover`openOnHover` is false.
dir"ltr" | "rtl"—headlessReading direction; see NavigationMenuReadingDirection. Inherited from the nearest DirectionProvider when omitted, falling back to "ltr".
onValueChange(value: string) => void—headlessCalled with the requested open value — the entry's value to open it, or "" to close whatever is open.
openOnHoverbooleantrueheadlessWhether hovering a trigger opens its panel. Set false for a click-only nav — hover-to-open is the desktop convention, but it has no touch equivalent and some products prefer to opt out.
orientation"horizontal" | "vertical""horizontal"headlessLayout axis; see NavigationMenuOrientation.
valuestring—headlessValue of the entry whose panel is open. "" means every panel is closed.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledEntry + panel scale; data-density scales the sizing within each size. Re-points every child sizing knob (entry height/padding/gap/icon, panel radius/padding/offset, row padding and text gap).

NavigationMenu.List

Extends HTMLUListElement — every native attribute of that element is accepted and forwarded.

No props of its own.

NavigationMenu.Item

Extends HTMLLIElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
valuestring—headlessIdentifies the entry's panel. Required for an entry that has a NavigationMenu.Trigger; omit it for a plain link entry.

NavigationMenu.Trigger

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRenders the child element instead of the default <button>. All disclosure ARIA attributes, the hover-intent and keyboard handlers, and the internal ref are merged onto the child via Slot. The child must be a single React element that accepts a ref.
refRef<T>—headlessRef to the rendered element. Defaults to HTMLButtonElement; when using asChild, specify the child's element type.

NavigationMenu.TriggerLabel

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

NavigationMenu.TriggerIcon

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

NavigationMenu.Content

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
forceMountbooleanfalseheadlessKeeps the closed panel out of the hidden state so CSS can animate it in and out, marking it aria-hidden instead so assistive technology still ignores it. Without this the panel is hidden when closed, which no transition can animate away from.

NavigationMenu.Viewport

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
forceMountbooleanfalseheadlessKeeps the viewport unhidden while nothing is open so CSS can animate the box collapsing and expanding. Mirrors NavigationMenuContentProps.forceMount`Content`'s.

NavigationMenu.Indicator

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRenders the child element instead of the default <div>, merging the data-* state hooks and the geometry custom properties onto it via Slot. Use it to make the marker an icon — an <svg> arrow, a <Chevron /> component — rather than a styled box. The child must be a single React element that accepts a ref.
forceMountbooleanfalseheadlessKeeps the indicator unhidden while nothing is open so CSS can animate it out rather than having it vanish.

Extends HTMLAnchorElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
activebooleanfalseheadlessMarks this link as the page the user is currently on, setting aria-current="page" and the data-active styling hook. The component does no route matching of its own — the consumer owns the router, so it owns the comparison.
asChildbooleanfalseheadlessRenders the child element instead of a native <a>, merging all of NavigationMenu.Link's props — aria-current, data-active, the panel-dismissing click handler, ref — onto it via Slot. Use for routing-library link components.

NavigationMenu.LinkText

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

NavigationMenu.LinkTitle

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

NavigationMenu.LinkDescription

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

NavigationMenu.LinkLeading

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

NavigationMenu.LinkTrailing

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Styling contract

Control frame

--primitiv-navigation-menu-entry-height--primitiv-navigation-menu-entry-padding-inline--primitiv-navigation-menu-entry-gap--primitiv-navigation-menu-entry-radius--primitiv-navigation-menu-entry-fg--primitiv-navigation-menu-entry-fg-hover--primitiv-navigation-menu-entry-fg-active--primitiv-navigation-menu-entry-fg-disabled--primitiv-navigation-menu-entry-bg--primitiv-navigation-menu-entry-bg-hover--primitiv-navigation-menu-entry-bg-active--primitiv-navigation-menu-entry-font-family--primitiv-navigation-menu-entry-font-size--primitiv-navigation-menu-entry-font-weight--primitiv-navigation-menu-entry-line-height--primitiv-navigation-menu-icon-size--primitiv-navigation-menu-disabled-opacity--primitiv-navigation-menu-surface--primitiv-navigation-menu-panel-radius--primitiv-navigation-menu-panel-padding-block--primitiv-navigation-menu-panel-padding-inline--primitiv-navigation-menu-panel-offset--primitiv-navigation-menu-safe-area--primitiv-navigation-menu-panel-shadow--primitiv-navigation-menu-panel-slide--primitiv-navigation-menu-row-padding-inline--primitiv-navigation-menu-row-padding-block--primitiv-navigation-menu-row-text-gap--primitiv-navigation-menu-row-bg-hover--primitiv-navigation-menu-row-title-color--primitiv-navigation-menu-row-description-color--primitiv-navigation-menu-row-description-font-family--primitiv-navigation-menu-row-description-font-size--primitiv-navigation-menu-row-description-font-weight--primitiv-navigation-menu-row-description-line-height--primitiv-navigation-menu-indicator-thickness--primitiv-navigation-menu-indicator-color--primitiv-navigation-menu-indicator-arrow-size--primitiv-navigation-menu-transition-duration--primitiv-navigation-menu-transition-easing

Panel

--primitiv-navigation-menu-content-gap--primitiv-navigation-menu-content-columns

Keyboard

KeyBehaviour
Tab / Shift+TabMove between the top-level entries — links and triggers alike are all tabbable.
Enter / SpaceToggle the focused trigger's panel; follow a focused link.
ArrowDownOpen the focused trigger's panel and move into it (down the entries in a vertical menu).
ArrowLeft / ArrowRightMove between top-level triggers (the axis follows orientation and dir).
EscapeClose the open panel and return focus to its trigger.

Data attributes

NavigationMenu

className: .primitiv-navigation-menu

AttributeValueWhen
data-orientationhorizontal | verticalhorizontal / vertical
data-stateopen | closedopen / closed
data-active""the link is the current page
data-motionfrom-start | from-end | to-start | to-endthe open entry changes and this panel is the one entering or leaving

NavigationMenuViewport

className: .primitiv-navigation-menu__viewport

AttributeValueWhen
data-value<item value>the open item's `value`, so a panel or marker can be styled per entry
data-navigation-menu-viewport-clip""always, on the wrapper that clips the morphing panel — the hook its own animation needs

NavigationMenuIndicator

className: .primitiv-navigation-menu__indicator

AttributeValueWhen
data-value<item value>the open item's `value`, so a panel or marker can be styled per entry

Accessibility

  • It is the WAI-ARIA APG Disclosure Navigation Menu, deliberately not a menubar. Every top-level entry stays in the tab order (no roving tabstop), which is the right model for links-to-pages — a menubar's single tab stop suits an application menu of commands, not site navigation.
  • An Item's value is the whole disclosure-vs-link distinction: with a value it is a Trigger + Content disclosure, without one it is a plain link. A Trigger inside a value-less Item throws in development, so the mistake is caught at the source.
  • active on a Link publishes data-active for styling but does not set aria-current — the semantic current-page marker is yours to add (aria-current="page"), because only you know whether the link points at the exact current URL.
  • Root owns the single Escape handler and returns focus to the open trigger on close. Hover-intent (delayDuration/closeDelay) is a convenience over that keyboard model, not a replacement — the menu is fully operable from the keyboard with openOnHover={false} too.
  • Give Root an aria-label (or aria-labelledby) so the <nav> landmark is named — a page with more than one nav needs each distinguished, and "Main" is the default.
  • There is no built-in mobile mode: compose the small-screen nav from Drawer + Collapsible, reusing NavigationMenuLink so a single a11y tree serves each breakpoint rather than shipping duplicate landmarks hidden by CSS.

Examples

Disclosure entries and panels

Give an Item a value and it becomes a disclosure: its Trigger opens a Content panel, and every panel projects into the shared Viewport so the open one morphs into the next. Hover or click a trigger to open it. The panel's layout is yours — Content is a grid whose track list is one custom property (--primitiv-navigation-menu-content-columns, 1fr by default), so Concepts here is two columns and Resources is one.

Density
import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport } from "@/components/ui/navigation-menu";import { ChevronDown } from "@primitiv-ui/icons";
<NavigationMenu aria-label="Docs">  <NavigationMenuList>    <NavigationMenuItem value="concepts">    <NavigationMenuTrigger>      <NavigationMenuTriggerLabel>Concepts</NavigationMenuTriggerLabel>      <NavigationMenuTriggerIcon><ChevronDown aria-hidden="true" /></NavigationMenuTriggerIcon>    </NavigationMenuTrigger>      <NavigationMenuContent forceMount style={{ "--primitiv-navigation-menu-content-columns": "repeat(2, minmax(0, 1fr))" }}>        <div>          <NavigationMenuLink placement="panel" href="#">            <NavigationMenuLinkText>              <NavigationMenuLinkTitle>Tokens</NavigationMenuLinkTitle>              <NavigationMenuLinkDescription>The three-tier token architecture</NavigationMenuLinkDescription>            </NavigationMenuLinkText>          </NavigationMenuLink>          {/* ...more rows */}        </div>      </NavigationMenuContent>    </NavigationMenuItem>  </NavigationMenuList>
  <NavigationMenuIndicator forceMount />  <NavigationMenuViewport forceMount /></NavigationMenu>

An Item with no value is a plain link, not a disclosure — no trigger, no panel — so a mega-menu and its ordinary links live in one list. Mark the current page with active on its Link: it publishes data-active for styling and does not change the a11y tree (the page's own aria-current is yours to set). Putting a Trigger inside a value-less Item throws — that pairing is the whole disclosure-vs-link distinction.

Density
import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuLink } from "@/components/ui/navigation-menu";
<NavigationMenu aria-label="Docs">  <NavigationMenuList>    {/* ...disclosure items... */}
    <NavigationMenuItem>      <NavigationMenuLink href="/changelog" active>Changelog</NavigationMenuLink>    </NavigationMenuItem>    <NavigationMenuItem>      <NavigationMenuLink href="/figma">Figma</NavigationMenuLink>    </NavigationMenuItem>  </NavigationMenuList></NavigationMenu>

Row slots

A panel row can carry more than a title. NavigationMenuLinkLeading holds a glyph before the text, NavigationMenuLinkTrailing one pinned to the inline-end edge, and the NavigationMenuLinkDescription is optional — drop it and the row collapses to a single line. Both slots are off until you render them. Open the Registry panel to see all three.

Density
import { NavigationMenu, NavigationMenuLink, NavigationMenuLinkLeading, NavigationMenuLinkText, NavigationMenuLinkTitle, NavigationMenuLinkDescription, NavigationMenuLinkTrailing } from "@/components/ui/navigation-menu";import { File, ChevronRight } from "@primitiv-ui/icons";
<NavigationMenuLink placement="panel" href="#">  <NavigationMenuLinkLeading><File aria-hidden="true" /></NavigationMenuLinkLeading>  <NavigationMenuLinkText>    <NavigationMenuLinkTitle>With row slots</NavigationMenuLinkTitle>    <NavigationMenuLinkDescription>Optional leading and trailing content</NavigationMenuLinkDescription>  </NavigationMenuLinkText>  <NavigationMenuLinkTrailing><ChevronRight aria-hidden="true" /></NavigationMenuLinkTrailing></NavigationMenuLink>

The open indicator

NavigationMenuIndicator marks the open trigger — it measures the trigger and tracks it as the open entry changes. marker="arrow" (the default) points a rotated square up at the trigger from the panel's edge; marker="underline" draws a rule beneath it instead. Open a trigger to see the underline slide between entries.

Density
import { NavigationMenu, NavigationMenuIndicator } from "@/components/ui/navigation-menu";
<NavigationMenuIndicator forceMount marker="underline" />

Mobile: compose, don't adapt

There is no mobile mode — a five-trigger mega-menu has no sane small-screen fallback of its own. Instead compose the small-screen nav from a Drawer and a Collapsible per section, reusing NavigationMenuLink so the nav data and the active-state logic stay single-sourced across both presentations (RFC 0019 §4a). NavigationMenu still wraps it — it renders the <nav> landmark and NavigationMenuLink reads its context — while Collapsible replaces List / Item / Trigger / Viewport. Open the menu to see it.

Density
import { NavigationMenu, NavigationMenuLink, NavigationMenuLinkText, NavigationMenuLinkTitle } from "@/components/ui/navigation-menu";import { Drawer, DrawerTrigger, DrawerPortal, DrawerContent, DrawerBody } from "@/components/ui/drawer";import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
<Drawer>  <DrawerTrigger asChild><Button>Open menu</Button></DrawerTrigger>  <DrawerPortal>    <DrawerContent side="left">      <DrawerBody>        <NavigationMenu aria-label="Docs (mobile)">          <Collapsible variant="plain">            <CollapsibleTrigger>Concepts</CollapsibleTrigger>            <CollapsibleContent>              <NavigationMenuLink placement="panel" href="#">                <NavigationMenuLinkText>                  <NavigationMenuLinkTitle>Tokens</NavigationMenuLinkTitle>                </NavigationMenuLinkText>              </NavigationMenuLink>            </CollapsibleContent>          </Collapsible>        </NavigationMenu>      </DrawerBody>    </DrawerContent>  </DrawerPortal></Drawer>