NavigationMenu
The links bar at the top of a site, with panels of links that open under their own trigger and stay on screen.
- Rest the pointer on Repositories: its panel opens under it. Move to Components and the same panel moves along and changes its content.
- With a mouse, a click on the open trigger keeps the panel open; Escape or a click elsewhere closes it.
- Tab to Repositories, press Enter, then Down to go into the panel; Escape brings you back to the trigger.
Field marks
- Import
import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink } from "cennge-ui";- Size
- 2.5 KB gzip for this import, its dependencies aside
- Pattern
- Disclosure navigation: a named
<nav>, triggers that are buttons witharia-expanded - Keyboard
- Enter SpaceArrow up Arrow down Arrow left Arrow right HomeEndEsc
- State
- The open panel: uncontrolled, or
valueandonValueChange - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Opens on
- Hover, or with
openOn="click"only a click; Enter and Space either way - Panel
- One for the whole bar, placed under the open trigger and kept inside the window
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: navigation-menu
When to use#
A NavigationMenu is the top bar of a site or of its documentation: a few triggers whose panels hold links, and plain links beside them. Everything in it takes the reader to another page, and it fits dozens of links into one row by keeping them in panels until someone asks.
The panel is one element shared by the whole bar, as in Radix, so moving from trigger to trigger swaps its content without closing and reopening. Unlike Radix, it opens under the trigger you are on rather than at the start of the bar, and it moves back inside the window when the trigger sits near an edge.
Similar#
- Dropdown runs actions from a button and is read as a menu of commands; a NavigationMenu holds links and is read as navigation.
- Sidebar keeps an app's links on screen in a column, and in a drawer on phones; a NavigationMenu hides them in panels to save the room.
Links and the current page#
NavigationMenuLink with a title and a description is a two-line link in a panel; alone in a NavigationMenuItem it is a link in the bar. With asChild it styles your router's link, here Next.js Link, and isActive highlights the current page and marks it with aria-current="page".
import Link from "next/link";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "cennge-ui";
const guides = [
{ href: "/docs/installation/", title: "Installation", description: "npm install, two CSS imports and one @source line" },
{ href: "/docs/theming/", title: "Theming", description: "Tokens, surfaces, colour roles and accents" },
{ href: "/docs/server-rendering/", title: "Next.js and SSR", description: "Server Components, hydration without warnings" },
];
/** In an app, pass usePathname() from next/navigation. */
export default function SiteHeader({ pathname = "/docs/navigation-menu/" }: { pathname?: string }) {
return (
<NavigationMenu aria-label="Site">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Guides</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-80 gap-1">
{guides.map((guide) => (
<li key={guide.href}>
<NavigationMenuLink asChild isActive={pathname === guide.href} title={guide.title} description={guide.description}>
<Link href={guide.href} />
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild isActive={pathname === "/docs/navigation-menu/"}>
<Link href="/docs/navigation-menu/">NavigationMenu</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
}Open on click#
openOn="click" stops the resting pointer from opening panels. A click, Enter or Space opens one; the pointer can leave it and it stays open until Escape, a click elsewhere or another click on its trigger.
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "cennge-ui";
const changelog = "https://github.com/Cennge/cennge-ui/blob/main/CHANGELOG.md";
const releases = [
{ version: "0.3.0", notes: "Charts, and theme switching without a flash" },
{ version: "0.2.0", notes: "Maps on MapLibre GL" },
{ version: "0.1.0", notes: "First release" },
];
const repositories = ["cennge-ui", "ai-niche-scout", "payload-cache", "playwright-msi-scraper"];
export default function ClickMenu() {
return (
<NavigationMenu aria-label="Project" openOn="click">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Releases</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-72 gap-1">
{releases.map((release) => (
<li key={release.version}>
<NavigationMenuLink
href={`${changelog}#${release.version.replaceAll(".", "")}`}
title={`cennge-ui@${release.version}`}
description={release.notes}
/>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Repositories</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-56 gap-1">
{repositories.map((name) => (
<li key={name}>
<NavigationMenuLink href={`https://github.com/Cennge/${name}`}>{name}</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
}Vertical#
With orientation="vertical" the triggers stack, the panel opens beside the trigger, level with it, and the right arrow goes into it. On a screen too narrow for both, the panel moves left over the list instead of off the edge.
import Link from "next/link";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "cennge-ui";
const families = [
{
title: "Inputs",
pages: [
{ slug: "combobox", title: "Combobox", description: "Type to filter, pick one or many" },
{ slug: "tags-input", title: "TagsInput", description: "Free text turned into tags with Enter" },
{ slug: "file-upload", title: "FileUpload", description: "Drop zone and file list with limits" },
],
},
{
title: "Overlays",
pages: [
{ slug: "dialog", title: "Dialog", description: "A modal with header, scrolling body and footer" },
{ slug: "drawer", title: "Drawer", description: "A panel from any edge of the screen" },
{ slug: "command", title: "Command", description: "A palette across actions and pages" },
],
},
{
title: "Charts",
pages: [
{ slug: "bar-chart", title: "BarChart", description: "Grouped or stacked bars" },
{ slug: "line-chart", title: "LineChart", description: "Series over time, with a crosshair tooltip" },
{ slug: "sparkline", title: "Sparkline", description: "A trend in a line of text or a stat card" },
],
},
];
export default function DocsMenu() {
return (
<NavigationMenu aria-label="Components" orientation="vertical" className="w-44">
<NavigationMenuList>
{families.map((family) => (
<NavigationMenuItem key={family.title}>
<NavigationMenuTrigger className="w-full justify-between">{family.title}</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-72 gap-1">
{family.pages.map((page) => (
<li key={page.slug}>
<NavigationMenuLink asChild title={page.title} description={page.description}>
<Link href={`/docs/${page.slug}/`} />
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
);
}Links in the HTML for search engines#
A panel is rendered only while it is open, so a crawler reading the page finds no links in it. keepMounted keeps every panel in the page, hidden while closed, so their links are in the HTML from the start.
import Link from "next/link";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "cennge-ui";
const charts = [
{ slug: "bar-chart", title: "BarChart", description: "Grouped or stacked bars, vertical or horizontal" },
{ slug: "line-chart", title: "LineChart", description: "Series over time, with a crosshair tooltip" },
{ slug: "area-chart", title: "AreaChart", description: "Lines with a wash below, stacked or not" },
{ slug: "donut-chart", title: "DonutChart", description: "Parts of a whole as a ring or a pie" },
];
export default function CrawlableMenu() {
return (
<NavigationMenu aria-label="Charts" keepMounted>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Charts</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[min(28rem,calc(100vw-4rem))] gap-1 sm:grid-cols-2">
{charts.map((chart) => (
<li key={chart.slug}>
<NavigationMenuLink asChild title={chart.title} description={chart.description}>
<Link href={`/docs/${chart.slug}/`} />
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild>
<Link href="/docs/charts/">Overview</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
}Accessibility#
Give each NavigationMenu an aria-label, so screen readers can tell it from the other navigation on the page. Triggers are buttons that say whether their panel is open; the arrow keys, Home and End move between the triggers and links of the bar, and Tab goes on into an open panel.