Navigation menu
A site's top navigation, with panels of links that open under their trigger.
Vibra draws the popup as the kit's elev-1 floating layer and lets it fade without shadcn's scale from 90%; triggers and links transition only their background and colour, and the indicator drops its shadow. The popup, its panels and the chevron move on --duration-slow and --duration-base rather than a fixed 0.35s and 300ms, so they stop under reduced motion. Triggers and links wear the kit's focus ring, where shadcn's trigger shows a half-strength halo and its panel links lose their outline altogether. From the keyboard, Enter or Down opens a panel, Tab walks into it and on past its end, and Escape closes it and returns to its trigger. Mark the page the reader is on with active; below sm, move the links into a Sheet.
Install
npx shadcn@latest add @vibra/navigation-menuNeeds the @vibra registry in your components.json — set it up once.
Examples
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu"
const PRODUCT = [
{ href: "/docs/components/stat-card", title: "Metrics", body: "Stat cards, deltas and goals." },
{ href: "/docs/components/area-chart", title: "Charts", body: "Area, bar, donut and more." },
{ href: "/docs/components/data-table", title: "Tables", body: "Sorting, filters and paging." },
]
const RESOURCES = [
{ href: "/docs/installation", title: "Installation", body: "Point the shadcn CLI at the registry." },
{ href: "/docs/theming", title: "Theming", body: "Tokens, palettes and density." },
]
export default function NavigationMenuDemo() {
return (
<NavigationMenu aria-label="Site">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Components</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-72 gap-1 p-1">
{PRODUCT.map((link) => (
<li key={link.href}>
<NavigationMenuLink href={link.href} className="flex-col items-start gap-0.5">
<span className="font-medium">{link.title}</span>
<span className="text-xs text-muted-foreground">{link.body}</span>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Docs</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-72 gap-1 p-1">
{RESOURCES.map((link) => (
<li key={link.href}>
<NavigationMenuLink href={link.href} className="flex-col items-start gap-0.5">
<span className="font-medium">{link.title}</span>
<span className="text-xs text-muted-foreground">{link.body}</span>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)
}With a featured card
A marketing header's Product panel leads with the release to be seen, on the surface plane, then the rest with an icon, a title and a line.
import { BellRingIcon, ChartLineIcon, FileSpreadsheetIcon, GaugeIcon, TrendingUpIcon } from "lucide-react"
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
const FEATURES = [
{ href: "/product/dashboards", title: "Dashboards", body: "Every figure the team watches, on one page.", icon: GaugeIcon },
{ href: "/product/reports", title: "Reports", body: "Scheduled PDFs for the board and the bank.", icon: FileSpreadsheetIcon },
{ href: "/product/alerts", title: "Alerts", body: "A message when a number crosses its line.", icon: BellRingIcon },
{ href: "/product/metrics", title: "Metrics", body: "MRR, churn and NRR, defined once.", icon: ChartLineIcon },
]
// Northwind's marketing header. The Product panel leads with the release the
// team wants seen — a card on the surface plane, not a picture — and lists
// the rest with an icon, a title and a line. Enter or Down opens a panel and
// Tab walks into it; Escape closes it and returns to its trigger.
export default function NavigationMenuFeatured() {
return (
<NavigationMenu aria-label="Northwind">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Product</NavigationMenuTrigger>
<NavigationMenuContent>
<div className="grid w-[min(34rem,calc(100vw-2rem))] gap-1 p-1 sm:grid-cols-[12rem_1fr]">
<NavigationMenuLink href="/product/forecasts" className="min-h-32 flex-col items-start gap-6 rounded-lg bg-surface p-3 ring-1 ring-border">
<TrendingUpIcon aria-hidden="true" className="size-5 shrink-0" />
<span className="mt-auto flex flex-col gap-1">
<span className="font-medium">Forecasts</span>{" "}
<span className="text-xs text-muted-foreground">New in September: next quarter's revenue before it lands.</span>
</span>
</NavigationMenuLink>
<ul className="grid gap-1">
{FEATURES.map(({ href, title, body, icon: Icon }) => (
<li key={href}>
<NavigationMenuLink href={href} className="items-start gap-3">
<Icon aria-hidden="true" className="mt-0.5 text-muted-foreground" />
<span className="flex flex-col gap-0.5">
<span className="font-medium">{title}</span>{" "}
<span className="text-xs text-muted-foreground">{body}</span>
</span>
</NavigationMenuLink>
</li>
))}
</ul>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/pricing" className={navigationMenuTriggerStyle()}>
Pricing
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/customers" className={navigationMenuTriggerStyle()}>
Customers
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)
}With badges and plain links
Six parts of the platform in two columns, "New" and "Beta" marked in words, and plain links in the row with the trigger's own look.
import { BotIcon, CalendarClockIcon, DatabaseIcon, PlugIcon, ShieldCheckIcon, WorkflowIcon } from "lucide-react"
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
const PLATFORM = [
{ href: "/platform/sources", title: "Data sources", body: "Payments, your database and 40 more", icon: DatabaseIcon },
{ href: "/platform/automations", title: "Automations", body: "Run a report when a number moves", icon: WorkflowIcon, badge: "New" },
{ href: "/platform/assistant", title: "Assistant", body: "Ask the numbers in plain words", icon: BotIcon, badge: "Beta" },
{ href: "/platform/schedules", title: "Schedules", body: "Reports on the first of the month", icon: CalendarClockIcon },
{ href: "/platform/integrations", title: "Integrations", body: "Chat, email and webhooks", icon: PlugIcon },
{ href: "/platform/security", title: "Security", body: "SSO, audit log and data regions", icon: ShieldCheckIcon },
]
// Six parts of the platform in two columns, and what just shipped marked in
// words — "New", "Beta" — the kit's outlined chip in the success tone, read
// with the link's name. Plain links sit in the same row as the triggers with
// the trigger's own look, so the row reads as one bar.
export default function NavigationMenuBadges() {
return (
<NavigationMenu aria-label="Northwind">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Platform</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[min(36rem,calc(100vw-2rem))] gap-1 p-1 sm:grid-cols-2">
{PLATFORM.map(({ href, title, body, icon: Icon, badge }) => (
<li key={href}>
<NavigationMenuLink href={href} className="items-start gap-3">
<span aria-hidden="true" className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
<Icon />
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-2 font-medium">
{title}
{badge ? " " : null}
{badge ? (
<span className="inline-flex h-5 items-center rounded-md border border-success/40 px-1 text-xs font-medium text-success">
{badge}
</span>
) : null}
</span>{" "}
<span className="text-xs text-muted-foreground">{body}</span>
</span>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/pricing" className={navigationMenuTriggerStyle()}>
Pricing
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/changelog" className={navigationMenuTriggerStyle()}>
Changelog
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)
}Marks the current page
The page's link is active — aria-current, on the brand tint — and its section's trigger is underlined and says the reader is in it.
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
const GUIDES = [
{ href: "/help/guides/connect-payments", title: "Connect a payment provider" },
{ href: "/help/guides/invoices", title: "Invoices and payments" },
{ href: "/help/guides/roles", title: "Roles and permissions" },
{ href: "/help/guides/exports", title: "Exports and schedules" },
]
// The help centre's bar on the "Invoices and payments" guide. The page's own
// link is `active` — aria-current="page", on the brand tint in the heavier
// weight — and its section's trigger is underlined in the accent and says, in
// words, that the reader is in it.
export default function NavigationMenuCurrent({ current = "/help/guides/invoices" }: { current?: string }) {
const inGuides = GUIDES.some((guide) => guide.href === current)
return (
<NavigationMenu aria-label="Help centre">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger
className={inGuides ? "relative after:absolute after:inset-x-2.5 after:bottom-0.5 after:h-0.5 after:rounded-full after:bg-brand" : undefined}
>
Guides
{inGuides ? <span className="sr-only">, the current section</span> : null}
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-60 gap-0.5 p-1">
{GUIDES.map((guide) => (
<li key={guide.href}>
<NavigationMenuLink
href={guide.href}
active={guide.href === current}
className="data-active:bg-brand-muted data-active:font-medium data-active:hover:bg-brand-muted"
>
{guide.title}
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/help/api" className={navigationMenuTriggerStyle()}>
API reference
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/changelog" className={navigationMenuTriggerStyle()}>
Changelog
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)
}On a phone
Below sm the same links move into a sheet behind one Menu button, grouped as in the panels; only one of the two ever shows.
import { MenuIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
const PRODUCT = [
{ href: "/product/dashboards", title: "Dashboards" },
{ href: "/product/reports", title: "Reports" },
{ href: "/product/alerts", title: "Alerts" },
]
const PAGES = [
{ href: "/pricing", title: "Pricing" },
{ href: "/customers", title: "Customers" },
]
// Panels that open under a bar need a bar's width. Below sm the same links
// move into a sheet behind one Menu button, grouped as they are in the
// panels; only one of the two is ever shown, so the links are met once.
export default function NavigationMenuPhone() {
return (
<>
<NavigationMenu aria-label="Northwind" className="max-sm:hidden">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Product</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-48 gap-0.5 p-1">
{PRODUCT.map((link) => (
<li key={link.href}>
<NavigationMenuLink href={link.href}>{link.title}</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
{PAGES.map((link) => (
<NavigationMenuItem key={link.href}>
<NavigationMenuLink href={link.href} className={navigationMenuTriggerStyle()}>
{link.title}
</NavigationMenuLink>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
<Sheet>
<SheetTrigger render={<Button variant="outline" className="sm:hidden" />}>
<MenuIcon aria-hidden="true" data-icon="inline-start" />
Menu
</SheetTrigger>
<SheetContent className="w-72 gap-2 sm:max-w-72">
<SheetHeader>
<SheetTitle>Northwind</SheetTitle>
</SheetHeader>
<nav aria-label="Northwind" className="flex flex-col gap-4 px-4">
<div className="flex flex-col gap-1">
<p id="navigation-menu-phone-product" className="type-eyebrow">
Product
</p>
<ul aria-labelledby="navigation-menu-phone-product" className="flex flex-col">
{PRODUCT.map((link) => (
<li key={link.href}>
<a href={link.href} className="flex h-9 items-center rounded-md px-2 text-sm focus-ring hover:bg-muted">
{link.title}
</a>
</li>
))}
</ul>
</div>
<ul className="flex flex-col border-t border-rule pt-3">
{PAGES.map((link) => (
<li key={link.href}>
<a href={link.href} className="flex h-9 items-center rounded-md px-2 text-sm focus-ring hover:bg-muted">
{link.title}
</a>
</li>
))}
</ul>
</nav>
</SheetContent>
</Sheet>
</>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| align | "start" | "center" | "end" | "start" | Where the popup lines up under the open trigger. |
| navigationMenuTriggerStyle | () => string | — | The trigger's classes, for a plain link that sits in the row beside the triggers. |
| NavigationMenuLink.active | boolean | false | The page the reader is on: aria-current="page" and data-active, for the link's own style. |
Dependencies
Source
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NavigationMenu({
align = "start",
className,
children,
...props
}: NavigationMenuPrimitive.Root.Props &
Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuPositioner align={align} />
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
// The kit's one focus treatment — a solid 2px outline in the accent —
// where shadcn's 3px halo at half strength measures under 2:1 on white.
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-[background-color,color] focus-ring hover:bg-muted focus:bg-muted disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
)
function NavigationMenuTrigger({
className,
children,
...props
}: NavigationMenuPrimitive.Trigger.Props) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon className="relative top-px ms-1 size-3 transition-transform duration-(--duration-base) ease-(--ease-standard) group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: NavigationMenuPrimitive.Content.Props) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto p-1 transition-[opacity,transform,translate] duration-(--duration-slow) ease-(--ease-emphasized) group-data-[viewport=false]/navigation-menu:elev-1 group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:duration-(--duration-base) data-ending-style:opacity-0 data-starting-style:opacity-0 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function NavigationMenuPositioner({
className,
side = "bottom",
sideOffset = 8,
align = "start",
alignOffset = 0,
...props
}: NavigationMenuPrimitive.Positioner.Props) {
return (
<NavigationMenuPrimitive.Portal>
<NavigationMenuPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className={cn(
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-(--duration-slow) ease-(--ease-emphasized) data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:end-0 data-[side=bottom]:before:start-0",
className
)}
{...props}
>
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) elev-1 text-popover-foreground transition-[opacity,transform,width,height,scale,translate] duration-(--duration-slow) ease-(--ease-emphasized) outline-none data-ending-style:opacity-0 data-ending-style:duration-(--duration-fast) data-starting-style:opacity-0">
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
</NavigationMenuPrimitive.Popup>
</NavigationMenuPrimitive.Positioner>
</NavigationMenuPrimitive.Portal>
)
}
function NavigationMenuLink({
className,
...props
}: NavigationMenuPrimitive.Link.Props) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-sm transition-[background-color,color] focus-ring hover:bg-muted focus:bg-muted in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) {
return (
<NavigationMenuPrimitive.Icon
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden duration-(--duration-fast) data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border" />
</NavigationMenuPrimitive.Icon>
)
}
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuPositioner,
}