Navigation Menu
A collection of links and menus for website navigation.
Last change: none
Codeapp/demos/public/navigation-menu.tsx
import { clientEntry, type Handle, type RemixNode } from 'remix/component'
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from '@stemsui/registry/ui/navigation-menu.tsx'
const components = [
{
title: 'Alert Dialog',
href: '/components/alert-dialog',
description: 'A modal dialog that interrupts the user with important content.',
},
{
title: 'Preview Card',
href: '/components/preview-card',
description: 'For sighted users to preview content available behind a link.',
},
{
title: 'Progress',
href: '/components/progress',
description: 'Displays an indicator showing the completion progress of a task.',
},
{
title: 'Scroll Area',
href: '/components/scroll-area',
description: 'Visually or semantically separates content.',
},
{
title: 'Tabs',
href: '/components/tabs',
description: 'Layered sections of content, displayed one at a time.',
},
{
title: 'Tooltip',
href: '/components/tooltip',
description: 'A popup that displays information related to an element on hover or focus.',
},
] as const
function ListItem(handle: Handle<{ title: string; href: string; children?: RemixNode }>) {
return () => (
<li>
<NavigationMenuLink href={handle.props.href} class="flex-col items-start gap-1">
<div class="text-sm leading-none font-medium">{handle.props.title}</div>
<p class="line-clamp-2 text-sm leading-snug text-muted-foreground">
{handle.props.children}
</p>
</NavigationMenuLink>
</li>
)
}
export const NavigationMenuDemo = clientEntry(
import.meta.url,
function NavigationMenuDemo(handle: Handle) {
let lastChange = 'none'
return () => (
<div class="flex min-h-80 w-full flex-col items-center gap-4">
<p class="text-sm text-muted-foreground" data-testid="navigation-menu-result">
Last change: <span class="font-medium text-foreground">{lastChange}</span>
</p>
<NavigationMenu
class="flex-none"
onValueChange={(value, details) => {
lastChange = `${value ?? 'closed'} (${details.reason})`
void handle.update()
}}
>
<NavigationMenuList>
<NavigationMenuItem value="getting-started">
<NavigationMenuTrigger data-testid="getting-started">
Getting started
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul class="grid w-96 gap-1 p-1 md:w-[28rem] md:grid-cols-[.75fr_1fr]">
<li class="row-span-3">
<NavigationMenuLink
href="/"
class="flex h-full w-full flex-col items-start justify-end rounded-md bg-linear-to-b from-muted/50 to-muted p-4 no-underline outline-hidden select-none focus:shadow-md md:p-6"
>
<div class="mb-2 text-lg font-medium sm:mt-4">Stems UI</div>
<p class="text-sm leading-tight text-muted-foreground">
Headless primitives and shadcn-styled components for Remix 3.
</p>
</NavigationMenuLink>
</li>
<ListItem href="/" title="Introduction">
Behavior from stems-ui, styles you own.
</ListItem>
<ListItem href="/" title="Installation">
Add components with the shadcn CLI.
</ListItem>
<ListItem href="/components/tabs" title="Composition">
Mix behavior onto your own elements.
</ListItem>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem value="components">
<NavigationMenuTrigger data-testid="components">Components</NavigationMenuTrigger>
<NavigationMenuContent>
<ul class="grid w-96 gap-1 p-1 md:w-[32rem] md:grid-cols-2 lg:w-[38rem]">
{components.map((component) => (
<ListItem title={component.title} href={component.href}>
{component.description}
</ListItem>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink
href="/components/navigation-menu"
active
class={navigationMenuTriggerStyle()}
data-testid="docs"
>
Docs
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
)
},
)
Installation
npx shadcn add @stemsui/navigation-menuRequires the one-time setup. Behavior: stems-ui/navigation-menu.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/navigation-menu
navigation-menu builds a site navigation bar. Its triggers open panels of links in one shared
popup. The popup stays open while the user moves between items and morphs to each panel's size.
App code owns the markup and visual styles.
Base UI equivalent: Navigation Menu.
Usage
import * as navigationMenu from 'stems-ui/navigation-menu'
function SiteNav() {
return () => (
<navigationMenu.Context>
<nav mix={navigationMenu.root()}>
<ul class="flex" mix={navigationMenu.list()}>
<navigationMenu.ItemContext value="docs">
<li>
<button mix={navigationMenu.trigger()}>
Docs <span mix={navigationMenu.icon()}>▾</span>
</button>
<navigationMenu.Content class="p-2 data-starting-style:opacity-0 data-ending-style:opacity-0">
<a href="/docs/intro" mix={navigationMenu.link({ active: true })}>
Introduction
</a>
<a href="/docs/install" mix={navigationMenu.link()}>
Installation
</a>
</navigationMenu.Content>
</li>
</navigationMenu.ItemContext>
<li>
<a href="/blog" mix={navigationMenu.link()}>
Blog
</a>
</li>
</ul>
<div
class="h-(--positioner-height) w-(--positioner-width)"
mix={navigationMenu.popup({ sideOffset: 8 })}
>
<div class="h-(--popup-height) w-(--popup-width) rounded-lg bg-white shadow transition-[width,height]">
<navigationMenu.Viewport class="relative size-full overflow-hidden" />
</div>
</div>
</nav>
</navigationMenu.Context>
)
}
Control the open item with value and onValueChange(value, details), or let the context own it
with defaultValue. Listen for bubbling changes on any ancestor:
<div mix={navigationMenu.onNavigationMenuChange((event) => console.log(event.value, event.reason))}>
…
</div>
Content goes into the viewport (no portals)
Base UI moves each item's Content into the popup's Viewport with a React portal. Stems has no
portals, and moving DOM nodes the reconciler owns would break later updates. So:
<navigationMenu.Content>is a component, not a mixin. Declare it inside its item, where it reads naturally. It renders nothing there. It registers its props and children with the root.<navigationMenu.Viewport>is a component too. It renders every registered content as a<div>child of the viewport element, with the content mixin applied. Inactive contents stay in the DOM withhidden.
What this means:
- Server HTML has every link, inside the viewport (Base UI needs
keepMountedfor this). Hydration matches, and Remix owns every node. - Render the viewport after the items (in render order) so the server HTML includes the
contents. The registry's
NavigationMenurenders it last. On the client, a viewport rendered first catches up with one extra render. - When the declaring component re-renders, the viewport re-renders. Changed children or props show up in the viewport.
- Context inside the content comes from the viewport's position. That is under the root
Context, but not under the item'sItemContext. A nestednavigationMenu.Contextinside a content still finds its parent menu.
Popup, positioner and the size morph
popup() is Base UI's Positioner and Popup in one top-layer element (popover="manual"). The
visible box is the viewport's parent. That can be the popup element itself, or an element
between the popup and the viewport, as in Base UI's Popup. When the user switches items while
the menu is open, these steps run:
- The entering content renders with
data-starting-style. The exiting content getsdata-ending-style,inert, and inlineposition: absolute; top: 0; left: 0, so it leaves the flow. - The new size is measured.
--positioner-widthand--positioner-heightjump to it on the popup element, soanchor()positions the popup for its final size.--popup-widthand--popup-heightstart at the old size of the visible box. On the next frame they change to the new size, so a CSStransitionon the box'swidthandheightanimates the change. - When the box's transitions finish, all four variables go back to
fit-content. Later content changes then apply directly.
When the popup is not morphing, the variables are fit-content, not Base UI's auto. A
[popover] element is position: fixed; inset: 0, so width: auto would make it as wide as the
viewport until it is positioned. The size variables are inherited, so you can set them on the
popup element and use them on the box inside it. With a single element, size it with
--popup-width and --popup-height. Do not transition top / left in that case: anchor()
reads the size while it animates, so the position would lag behind.
Differences from Base UI
- No Portal or Positioner element.
popup()is one element in the top layer. Base UI's Positioner props are options ofpopup(). ContentandViewportare components (see above).keepMountedis not needed: contents are always mounted.- The Item part is the
ItemContextprovider. Put it around your<li>. The<li>needs no mixin. - Nested menus: put a
navigationMenu.Contextinside a content. It getsdata-nested.- It can have its own popup (opened to the side), or only a
Listand an inlineViewport. - An inline nested menu morphs the parent's popup when it switches items.
- Nested popups close when the parent switches or closes.
- Escape closes the innermost popup first.
- It can have its own popup (opened to the side), or only a
- Hover close with an inline viewport. A menu without a popup never closes on hover. Its items only switch.
- Not supported:
actionsRef/preventUnmountOnClose,data-instant, andaria-ownsre-parenting. AlsocollisionAvoidance,collisionBoundary,sticky,positionMethod, anddisableAnchorTracking. - Base UI's Popup renders
<nav>. Here you choose the element. - Custom content
id. The trigger'saria-controlsuses it after the first client render. Server HTML has the generated id.
stems-ui/navigation-menu
Context: the provider that owns the open item. Read it withhandle.context.get(navigationMenu.Context). It hasvalue,open,orientation,nested,activationDirection,openReason,setValue(value, { reason?, event? }), andclose().value:string | null. Controlled open item.nullcloses the menu.defaultValue:string | null. Initial uncontrolled item. Defaults tonull.onValueChange(value, details): called before the value changes.detailshasreason,event,trigger, andcancel().onOpenChangeComplete(open): called after the popup's enter or exit animation finished.delay:number. Hover-open delay in ms. Defaults to50.closeDelay:number. Hover-close delay in ms. Defaults to50.orientation:'horizontal'(default) |'vertical'.
ItemContext: one item (Base UIItem).value:string. Defaults to a generated id.
root(): the root element, usually<nav>. Setsdata-orientationanddata-nested. Handles focus-out.list(): the item list, usually<ul>. Setsdata-orientation. Handles arrow keys.trigger(options?): the item's button. Setstype="button",aria-expanded, andaria-controls(the content's id). Setsdata-popup-open,data-pressed(open, and not just hovered), anddata-disabled. A non-<button>host getsrole="button",tabIndex, and Enter/Space handling.disabled:boolean. The trigger ignores presses and hover. It stays focusable and getsaria-disabled.
icon(): an icon inside the trigger. Setsaria-hiddenanddata-popup-open.link(options?): a plain<a>, in the list or in a content.active:boolean. Addsaria-current="page"anddata-active.closeOnClick:boolean. Closes the menu (reason'link-press'). Defaults tofalse.
<Content {...divProps}>: the item's content. Rendered as a<div>inside the viewport (see above). Hasid,hidden,data-open/data-closed,data-activation-direction, and the presence attributes.<Viewport {...divProps} />: the clipping viewport. Renders the contents, and a visually hidden focus guard at each end.popup(options?): the shared popup. Setsid,popover="manual",tabIndex={-1},hiddenwhile closed, and the attributes and variables below. It never moves focus when it opens.- Options:
side(default'bottom'),align(default'center'),sideOffset,alignOffset,collisionPadding(default5),arrowPadding, andanchor. These are the same asstems-ui/popover'spopup().
- Options:
arrow(): points at the active trigger. Same asstems-ui/popover.backdrop(): an optional overlay in the top layer, below the popup.onNavigationMenuChange(handler): a listener forNavigationMenuChangeEvent(stems:navigation-menu-change). The event bubbles from the trigger. It hasvalue,previousValue, andreason.
Change reasons
'trigger-press''trigger-hover''outside-press''list-navigation'(ArrowDown, or ArrowRight in a vertical menu)'focus-out''escape-key''link-press''imperative-action'(setValue/close)'none'(a parent menu closed this nested one)
Behavior notes
- Navigation, not a menu. There are no
menuormenuitemroles. Triggers are buttons witharia-expandedandaria-controls. Links are plain<a>elements. - Hover (mouse and pen; touch is ignored):
- Entering a trigger opens it after
delay. - While the menu is open, entering another trigger switches to it right away.
- A hover-opened menu closes
closeDelayms after the pointer leaves the triggers and popup. Moving toward the popup keeps it open (lib/hover-aim.ts).
- Entering a trigger opens it after
- Press: toggles the item, or switches to it. A press within 500 ms of a hover-open keeps the menu open (Base UI's "patient click"). After that, hover no longer closes the menu.
- Keyboard:
- In the list, the arrow keys move between the top-level triggers and links. They wrap, and Home/End go to the first and last. Horizontal menus use Left/Right (mirrored in RTL). Vertical menus use Up/Down.
- ArrowDown opens the trigger's item and focuses its first link. In a vertical menu, use ArrowRight (ArrowLeft in RTL).
- Up/Down move between the links in a content.
- Escape closes and returns focus to the trigger.
- Tab order: the popup renders after the list, so these rules keep the order logical.
- Tab from an open trigger goes into its content.
- Tab from the last link in the content goes to the element after the trigger.
- Shift+Tab from the first link goes back to the trigger.
- Focus guards skip the popup when the user tabs into it from elsewhere.
- The menu closes (reason
'focus-out') when focus leaves the root and the popup.
- Dismissal: this uses the shared dismiss-layer stack. An outside press closes the menu. The triggers count as inside.
- Closing: the content stays shown (
data-open) while the popup animates out. It is hidden instantly afterward. - Activation direction: when the user switches items while the menu is open,
data-activation-directionon every content isleft/right(horizontal) orup/down(vertical). The value compares the trigger positions. It is cleared when the menu opens fresh.
Styling hooks
| Part | Attribute | Values / notes |
|---|---|---|
| root | data-orientation | horizontal | vertical |
| root | data-nested | present on a nested menu |
| list | data-orientation | horizontal | vertical |
| trigger | aria-expanded | "true" | "false" |
| trigger | data-popup-open | present while its item is open |
| trigger | data-pressed | present while open, unless only hovered open |
| trigger | data-disabled | present when disabled |
| icon | data-popup-open | present while its item is open |
| link | data-active | present with active (aria-current="page") |
| content | data-open / data-closed | whether it's the shown content |
| content | data-activation-direction | left | right | up | down |
| content | data-starting-style | present on the first frame of entering |
| content | data-ending-style | present while exiting (also inert, absolute) |
| content | hidden | present when not shown and not animating |
| viewport | data-orientation | horizontal | vertical |
| popup | data-open / data-closed | open state |
| popup | data-side / data-align | final placement |
| popup | data-starting-style | present on the first frame of opening |
| popup | data-ending-style | present while animating out |
| arrow | data-side / data-align | same as the popup; data-uncentered, data-open |
| backdrop | data-open / data-closed | plus the presence attributes |
| Part | CSS variable | Value |
|---|---|---|
| popup | --popup-width / --popup-height | the visible box's size: animates between items, else fit-content |
| popup | --positioner-width / --positioner-height | the final size during a morph, else fit-content |
| popup | --anchor-width / --anchor-height | the active trigger's size in px |
| popup | --available-width / --available-height | space available on the final side, in px |
| popup | --transform-origin | the active trigger relative to the popup |