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
Preview
import{NavigationMenu,NavigationMenuList,NavigationMenuItem,NavigationMenuTrigger,NavigationMenuContent,NavigationMenuLink,NavigationMenuIndicator,NavigationMenuViewport}from"@/components/ui/navigation-menu";import{ChevronDown}from"@primitiv-ui/icons";<NavigationMenusize="md"aria-label="Docs"><NavigationMenuList><NavigationMenuItemvalue="concepts"><NavigationMenuTrigger><NavigationMenuTriggerLabel>Concepts</NavigationMenuTriggerLabel><NavigationMenuTriggerIcon><ChevronDown aria-hidden="true" /></NavigationMenuTriggerIcon></NavigationMenuTrigger><NavigationMenuContentforceMount><NavigationMenuLinkplacement="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><NavigationMenuLinkhref="#"active>Changelog</NavigationMenuLink></NavigationMenuItem></NavigationMenuList><NavigationMenuIndicatorforceMount/><NavigationMenuViewportforceMount/></NavigationMenu>
import{NavigationMenu}from"@primitiv-ui/react";import{ChevronDown}from"@primitiv-ui/icons";<NavigationMenu.Rootsize="md"aria-label="Docs"><NavigationMenu.List><NavigationMenu.Itemvalue="concepts"><NavigationMenu.Trigger> Concepts<ChevronDownaria-hidden="true"/></NavigationMenu.Trigger><NavigationMenu.ContentforceMount><NavigationMenu.Linkhref="#"><spanclassName="title">Tokens</span><spanclassName="description">The three-tier token architecture</span></NavigationMenu.Link>{/* ...more rows */}</NavigationMenu.Content></NavigationMenu.Item>{/* A value-less Item is a plain link, not a disclosure. */}<NavigationMenu.Item><NavigationMenu.Linkhref="#"active>Changelog</NavigationMenu.Link></NavigationMenu.Item></NavigationMenu.List><NavigationMenu.IndicatorforceMount/><NavigationMenu.ViewportforceMount/></NavigationMenu.Root>
Density is set by a data-density ancestor — the Context system, not a NavigationMenu prop.
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
Eight parts in @primitiv-ui/react: Root (the <nav>), List, Item, Trigger, Content, Viewport, Indicator and one Link (used both on the bar and inside a panel via its placement modifier). The copied file adds seven presentational slots — TriggerLabel/TriggerIcon on the trigger, and LinkText/LinkTitle/LinkDescription/LinkLeading/LinkTrailing inside a panel row — so under the Headless tab those are your own elements. Content projects into the shared Viewport, which is what lets the open panel morph into the next.
<NavigationMenu><NavigationMenuList><NavigationMenuItemvalue="concepts"><NavigationMenuTrigger><NavigationMenuTriggerLabel/><NavigationMenuTriggerIcon/></NavigationMenuTrigger><NavigationMenuContent><NavigationMenuLinkplacement="panel"><NavigationMenuLinkLeading/>{/* optional */}<NavigationMenuLinkText><NavigationMenuLinkTitle/><NavigationMenuLinkDescription/>{/* optional */}</NavigationMenuLinkText><NavigationMenuLinkTrailing/>{/* optional */}</NavigationMenuLink></NavigationMenuContent></NavigationMenuItem><NavigationMenuItem><NavigationMenuLink/>{/* a plain bar link */}</NavigationMenuItem></NavigationMenuList><NavigationMenuIndicator/><NavigationMenuViewport/></NavigationMenu>
<NavigationMenu.Root><NavigationMenu.List><NavigationMenu.Itemvalue="concepts"><NavigationMenu.Trigger>{/* your label + icon */}</NavigationMenu.Trigger><NavigationMenu.Content>{/* your panel */}</NavigationMenu.Content></NavigationMenu.Item><NavigationMenu.Item><NavigationMenu.Link/>{/* a plain bar link */}</NavigationMenu.Item></NavigationMenu.List><NavigationMenu.Indicator/><NavigationMenu.Viewport/></NavigationMenu.Root>// TriggerLabel/TriggerIcon and the panel-row Link* parts are// styled-surface only — in headless a trigger and a panel row// are your own markup.
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
NavigationMenu.Root
Extends HTMLElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
closeDelay
number
150
headless
Milliseconds 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.
defaultValue
string
—
headless
Value of the entry whose panel is open on first render. Omit (or pass
"") to start with everything closed.
delayDuration
number
200
headless
Milliseconds 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"
—
headless
Reading direction; see NavigationMenuReadingDirection. Inherited
from the nearest DirectionProvider when omitted, falling back to
"ltr".
onValueChange
(value: string) => void
—
headless
Called with the requested open value — the entry's value to open it, or
"" to close whatever is open.
openOnHover
boolean
true
headless
Whether 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"
headless
Layout axis; see NavigationMenuOrientation.
value
string
—
headless
Value of the entry whose panel is open. "" means every panel is
closed.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Entry + 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.
Prop
Type
Default
From
Description
value
string
—
headless
Identifies 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Renders 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.
ref
Ref<T>
—
headless
Ref 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.
Prop
Type
Default
From
Description
forceMount
boolean
false
headless
Keeps 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.
Prop
Type
Default
From
Description
forceMount
boolean
false
headless
Keeps 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Renders 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.
forceMount
boolean
false
headless
Keeps the indicator unhidden while nothing is open so CSS can animate it
out rather than having it vanish.
NavigationMenu.Link
Extends HTMLAnchorElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
active
boolean
false
headless
Marks 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.
asChild
boolean
false
headless
Renders 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
42 CSS custom properties on .primitiv-navigation-menu — mode-agnostic. These names are the stable surface; the values are not.
It is the APG Disclosure Navigation Menu, not a menubar — so every top-level entry stays tabbable (there is no roving tabstop to trap you on one). Hover-intent opens a panel after delayDuration and closes it after closeDelay; openOnHover={false} makes it click-only.
Key
Behaviour
Tab / Shift+Tab
Move between the top-level entries — links and triggers alike are all tabbable.
Enter / Space
Toggle the focused trigger's panel; follow a focused link.
ArrowDown
Open the focused trigger's panel and move into it (down the entries in a vertical menu).
ArrowLeft / ArrowRight
Move between top-level triggers (the axis follows orientation and dir).
Escape
Close the open panel and return focus to its trigger.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
NavigationMenu
className:.primitiv-navigation-menu
Attribute
Value
When
data-orientation
horizontal | vertical
horizontal / vertical
data-state
open | closed
open / closed
data-active
""
the link is the current page
data-motion
from-start | from-end | to-start | to-end
the open entry changes and this panel is the one entering or leaving
NavigationMenu.Root
Attribute
Value
When
data-orientation
horizontal | vertical
horizontal / vertical
data-state
open | closed
open / closed
data-active
""
the link is the current page
data-motion
from-start | from-end | to-start | to-end
the open entry changes and this panel is the one entering or leaving
NavigationMenuViewport
className:.primitiv-navigation-menu__viewport
Attribute
Value
When
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
NavigationMenu.Viewport
Attribute
Value
When
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
Attribute
Value
When
data-value
<item value>
the open item's `value`, so a panel or marker can be styled per entry
NavigationMenu.Indicator
Attribute
Value
When
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
Every example below reacts to the density control. Currently showing Styled mode.
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.
An Item with novalue 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.
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.
import{NavigationMenu,NavigationMenuLink,NavigationMenuLinkLeading,NavigationMenuLinkText,NavigationMenuLinkTitle,NavigationMenuLinkDescription,NavigationMenuLinkTrailing}from"@/components/ui/navigation-menu";import{File,ChevronRight}from"@primitiv-ui/icons";<NavigationMenuLinkplacement="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>
import{NavigationMenu}from"@primitiv-ui/react";<NavigationMenu.Linkhref="#">{/* your own leading icon, title, description and trailing glyph */}</NavigationMenu.Link>
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.
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.