NavigationMenu
Site navigation with flyout panels. A bar of muted text links, where each trigger reveals an elevated mega-panel — the box morphs to each panel's size while the panels cross-slide in place. The bar reads like Tabs, the panels like Menu.
Flyout panels
Compose a mega-menu from blessed parts: each trigger's NavigationMenu.Content holds a list of links, where every NavigationMenu.Link pairs a NavigationMenu.LinkTitle over a muted NavigationMenu.LinkDescription. Lay them out however the panel calls for — a multi-column grid here, a single column there. Each panel sizes to its own content, so panels of different shapes give the flyout distinct footprints. Move between the triggers: the single elevated popup morphs its width and height to each panel while the outgoing panel slides and fades out and the incoming one slides in — one continuous motion, never a jump, and the bar always opens downward (it never flips above itself). Bar items are text links that lift to a neutral ghost fill (the focus ring is the one accent use); a plain NavigationMenu.Link in an Item is a direct bar link with no flyout.
"use client";
import { NavigationMenu } from "@stridge/noctis";
const overviewLinks = [
{ href: "#quick-start", title: "Quick start", description: "Install and assemble your first component." },
{ href: "#accessibility", title: "Accessibility", description: "Learn how we build accessible components." },
{ href: "#releases", title: "Releases", description: "See what’s new in the latest versions." },
{ href: "#about", title: "About", description: "Learn more about the project and our mission." },
];
const handbookLinks = [
{ href: "#styling", title: "Styling", description: "Style components with plain CSS, Tailwind, or CSS Modules." },
{ href: "#animation", title: "Animation", description: "Animate with CSS transitions, CSS animations, or JS." },
{ href: "#composition", title: "Composition", description: "Replace and compose parts with your own components." },
];
/** One panel link: a title over a muted description. */
function PanelLink({ href, title, description }: { href: string; title: string; description: string }) {
return (
<li>
<NavigationMenu.Link href={href}>
<NavigationMenu.LinkTitle>{title}</NavigationMenu.LinkTitle>
<NavigationMenu.LinkDescription>{description}</NavigationMenu.LinkDescription>
</NavigationMenu.Link>
</li>
);
}
export default function NavigationMenuRichPanels() {
return (
<NavigationMenu.Root>
<NavigationMenu.List>
{/* Overview — a two-column grid of links: the wider, shorter panel. */}
<NavigationMenu.Item>
<NavigationMenu.Trigger>Overview</NavigationMenu.Trigger>
<NavigationMenu.Content className="max-w-lg">
<ul className="m-0 grid list-none grid-cols-2 p-0">
{overviewLinks.map((link) => (
<PanelLink key={link.href} {...link} />
))}
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
{/* Handbook — a single column of links: the narrower, taller panel. */}
<NavigationMenu.Item>
<NavigationMenu.Trigger>Handbook</NavigationMenu.Trigger>
<NavigationMenu.Content className="max-w-sm">
<ul className="m-0 flex list-none flex-col p-0">
{handbookLinks.map((link) => (
<PanelLink key={link.href} {...link} />
))}
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
{/* A plain link sits in the bar with no flyout. */}
<NavigationMenu.Item>
<NavigationMenu.Link href="https://github.com/stridge-foundation/noctis">GitHub</NavigationMenu.Link>
</NavigationMenu.Item>
</NavigationMenu.List>
<NavigationMenu.Viewport />
</NavigationMenu.Root>
);
}
Nested submenu
A NavigationMenu.Root can be nested inside a NavigationMenu.Content to add a second level. The inner menu gets its own NavigationMenu.Viewport — here pointed to the side so the submenu flies out beside the panel. A trigger nested in a panel reads as a panel row, not a bar pill.
"use client";
import { NavigationMenu } from "@stridge/noctis";
const overviewLinks = [
{ href: "#quick-start", title: "Quick start", description: "Install and assemble your first component." },
{ href: "#accessibility", title: "Accessibility", description: "Learn how we build accessible components." },
{ href: "#releases", title: "Releases", description: "See what’s new in the latest versions." },
];
const handbookLinks = [
{ href: "#styling", title: "Styling", description: "Plain CSS, Tailwind, or CSS Modules." },
{ href: "#animation", title: "Animation", description: "CSS transitions, animations, or JavaScript." },
{ href: "#composition", title: "Composition", description: "Compose parts with your own components." },
];
function PanelLink({ href, title, description }: { href: string; title: string; description: string }) {
return (
<li>
<NavigationMenu.Link href={href}>
<NavigationMenu.LinkTitle>{title}</NavigationMenu.LinkTitle>
<NavigationMenu.LinkDescription>{description}</NavigationMenu.LinkDescription>
</NavigationMenu.Link>
</li>
);
}
export default function NavigationMenuNestedSubmenu() {
return (
<NavigationMenu.Root>
<NavigationMenu.List>
<NavigationMenu.Item>
<NavigationMenu.Trigger>Overview</NavigationMenu.Trigger>
<NavigationMenu.Content className="max-w-xs">
<ul className="m-0 flex list-none flex-col p-0">
{overviewLinks.map((link) => (
<PanelLink key={link.href} {...link} />
))}
{/* A second-level menu nested in the panel — it flies out to the side. */}
<li>
<NavigationMenu.Root orientation="vertical">
<NavigationMenu.List>
<NavigationMenu.Item>
<NavigationMenu.Trigger>
<NavigationMenu.LinkTitle>Handbook</NavigationMenu.LinkTitle>
<NavigationMenu.LinkDescription>
Styling, animation, and composition.
</NavigationMenu.LinkDescription>
</NavigationMenu.Trigger>
<NavigationMenu.Content className="max-w-xs">
<ul className="m-0 flex list-none flex-col p-0">
{handbookLinks.map((link) => (
<PanelLink key={link.href} {...link} />
))}
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
</NavigationMenu.List>
<NavigationMenu.Viewport side="right" align="start" sideOffset={8} alignOffset={-8} />
</NavigationMenu.Root>
</li>
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Item>
<NavigationMenu.Link href="#pricing">Pricing</NavigationMenu.Link>
</NavigationMenu.Item>
</NavigationMenu.List>
<NavigationMenu.Viewport />
</NavigationMenu.Root>
);
}
Sidebar menu
For a mega-panel with its own internal navigation, nest a vertical NavigationMenu.Root and render its NavigationMenu.Viewport with inline — the panels stay inside the layout (no second portal) and slide vertically as you move down the sidebar. The outer flyout's card provides the surface.
"use client";
import { NavigationMenu } from "@stridge/noctis";
const audienceMenus = [
{
value: "developers",
label: "Developers",
hint: "Go from idea to UI faster.",
title: "Build product UI without giving up control",
description: "Start with accessible parts and shape them to your app, not the other way around.",
links: [
{ href: "#quick-start", title: "Quick start", description: "Get your first component on screen fast." },
{ href: "#composition", title: "Composition", description: "Combine parts to match your structure." },
],
},
{
value: "systems",
label: "Design systems",
hint: "Keep patterns aligned across teams.",
title: "Turn shared standards into working components",
description: "Wire tokens, states, and accessibility once, then hand every team the same starting point.",
links: [
{ href: "#styling", title: "Styling", description: "Map tokens and states to your own setup." },
{ href: "#accessibility", title: "Accessibility", description: "Keyboard and semantics by default." },
],
},
{
value: "leads",
label: "Engineering leads",
hint: "Roll out shared UI without drag.",
title: "Give squads clear defaults and room to move",
description: "Align on quality bars and upgrades while leaving teams space to customise.",
links: [
{ href: "#releases", title: "Releases", description: "Track changes before they surprise teams." },
{ href: "#composition", title: "Composition", description: "Share behaviour with flexible APIs." },
],
},
];
const learnLinks = [
{ href: "#accessibility", title: "Accessibility", description: "Focus order, semantics, and keyboard support." },
{ href: "#styling", title: "Styling", description: "Apply tokens and state styles cleanly." },
{ href: "#composition", title: "Composition", description: "When to wrap, share, and expose APIs." },
];
function PanelLink({ href, title, description }: { href: string; title: string; description: string }) {
return (
<li>
<NavigationMenu.Link href={href}>
<NavigationMenu.LinkTitle>{title}</NavigationMenu.LinkTitle>
<NavigationMenu.LinkDescription>{description}</NavigationMenu.LinkDescription>
</NavigationMenu.Link>
</li>
);
}
export default function NavigationMenuSidebarMenu() {
return (
<NavigationMenu.Root>
<NavigationMenu.List>
{/* Product — a mega-panel with its own vertical sidebar nav, rendered inline. */}
<NavigationMenu.Item>
<NavigationMenu.Trigger>Product</NavigationMenu.Trigger>
<NavigationMenu.Content className="p-0">
<NavigationMenu.Root orientation="vertical" defaultValue="developers">
<div className="flex">
<NavigationMenu.List className="w-52 shrink-0 flex-col border-e border-border p-2">
{audienceMenus.map((menu) => (
<NavigationMenu.Item key={menu.value} value={menu.value}>
<NavigationMenu.Trigger>
<NavigationMenu.LinkTitle>{menu.label}</NavigationMenu.LinkTitle>
<NavigationMenu.LinkDescription>{menu.hint}</NavigationMenu.LinkDescription>
</NavigationMenu.Trigger>
<NavigationMenu.Content className="h-auto w-80">
<div className="flex flex-col gap-3 p-2">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-foreground">{menu.title}</span>
<span className="text-sm text-muted">{menu.description}</span>
</div>
<ul className="m-0 flex list-none flex-col p-0">
{menu.links.map((link) => (
<PanelLink key={link.href + menu.value} {...link} />
))}
</ul>
</div>
</NavigationMenu.Content>
</NavigationMenu.Item>
))}
</NavigationMenu.List>
<NavigationMenu.Viewport inline className="h-auto min-h-72 w-80" />
</div>
</NavigationMenu.Root>
</NavigationMenu.Content>
</NavigationMenu.Item>
{/* Learn — a plain single panel, so the box morphs from the sidebar to a simple list. */}
<NavigationMenu.Item>
<NavigationMenu.Trigger>Learn</NavigationMenu.Trigger>
<NavigationMenu.Content className="max-w-xs">
<ul className="m-0 flex list-none flex-col p-0">
{learnLinks.map((link) => (
<PanelLink key={link.href} {...link} />
))}
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Item>
<NavigationMenu.Link href="https://github.com/stridge-foundation/noctis">GitHub</NavigationMenu.Link>
</NavigationMenu.Item>
</NavigationMenu.List>
<NavigationMenu.Viewport />
</NavigationMenu.Root>
);
}
The bar collapses badly on touch, so below a breakpoint hide it and drop to a Sheet drawer holding a vertical NavigationMenu (orientation="vertical"), toggled by a hamburger Button with an aria-label. For SEO, set keepMounted on a Content so its links stay in the DOM for crawlers; wire each Link's active from your router (usePathname() === href → aria-current="page") and pass closeOnClick to dismiss the panel on navigation. For a compact docs bar, set size="sm" on Root; open timing is tuned by delay / closeDelay (Noctis defaults ~150 / ~180ms).
Keyboard
The bar is fully keyboard-operable, following the WAI-ARIA disclosure-navigation pattern (native <nav> + links — never role="menu").
| Key | Action |
|---|---|
Arrow Left / Arrow Right | Move between top-level items in the bar. Mirrored under RTL — Arrow Left moves toward the inline-end. |
Home / End | Jump to the first / last top-level item. |
Enter / Space | Open the focused trigger's flyout (or follow a focused link). |
Arrow Down | Open the focused trigger's flyout and move focus into its panel. |
Tab / Shift + Tab | Move through the links inside the open panel, then out of the bar. |
Escape | Close the open flyout and return focus to its trigger. |
Accessibility
- Disclosure, not menu. Site navigation uses native semantics —
<nav>→<ul>/<li>→<button aria-expanded aria-controls>for a flyout trigger and<a>for a link. It deliberately does not userole="menu"/menubar, which are for application menus and impose composite focus and typeahead that confuse screen-reader users on site nav. - One current per level. The current page's link carries
aria-current="page"(set byactive); never signal the current location by colour alone — the selected fill pairs with it, and acurrentsection keeps its fill so "you are here" survives a panel close. - Disabled. A
disabledtrigger or link is dimmed, removed from the tab order, and can't be activated (not justaria-disabled). - RTL. All geometry is logical, so the bar, the cross-slide, and the panel padding mirror under
dir="rtl". - Reduced motion. The size-morph, cross-slide, and chevron rotation are all disabled under
prefers-reduced-motion: reduce.
Anatomy
Compose a navigation menu from its parts. NavigationMenu.Root owns the open value (controlled via value / onValueChange, or uncontrolled via defaultValue) and carries the bar size, orientation, and hover-intent delay / closeDelay.
NavigationMenu.Root— the container, rendering a<nav>. Props: the Base UINavigationMenu.Rootprops plussize(md|sm).NavigationMenu.List— the bar of top-level items. Arrow keys and Home/End rove between them.NavigationMenu.Item— one entry. Wrap aTrigger+Contentfor a flyout, or a singleLinkfor a direct page.NavigationMenu.Trigger— opens an item's flyout; carries a trailing chevron that rotates while open. Mark itcurrentfor the persistent section indicator;disabledto dim it.NavigationMenu.Content— the flyout panel for an item, moved into the shared viewport while active.keepMountedkeeps its links in the DOM for crawlers.NavigationMenu.Link— a link, in the bar or inside a panel.activemarks the current page;currentmarks the current section in the bar;closeOnClickdismisses the panel on click.NavigationMenu.LinkTitle/NavigationMenu.LinkDescription— the prominent line and the muted line of a two-line panel link.NavigationMenu.Section/NavigationMenu.SectionTitle— a titled column group inside a panel and its heading label.NavigationMenu.Separator— a sharp 1px divider between panel groups.NavigationMenu.Footer— a pinned "view all"/CTA row at a panel's end.NavigationMenu.Viewport— the single, elevated, portaled flyout the active panel teleports into; it morphs to each panel's size and clips the panels as they cross-slide. Render it once insideRoot, after theList.backdropdims the page behind it.
Every part carries a data-slot for host-side styling — pair it with the state attributes (data-popup-open / data-current / data-disabled on a trigger, data-active on the current link, data-activation-direction on the sliding panel, data-size / data-orientation on the root). The flyout is an elevated <Surface elevation="menu"> with data-elevation on the portaled popup, and the panel transitions respect prefers-reduced-motion.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every navigation bar in that region retunes — e.g. .brand { --noctis-navigation-menu-positioner-duration: 0.5s; } slows the whole flyout's morph and slide. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through. Expand a row for the full type and description.