Site navigation with dropdowns, or one full-width panel that stays open while its content slides between menus.
import {
IconBook,
IconBrandGithub,
IconBuildingStore,
IconChartBar,
IconCode,
IconComponents,
IconLayoutDashboard,
IconLifebuoy,
IconPalette,
IconRocket,
IconShieldLock,
IconUsers,
} from "@tabler/icons-react"
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu"
type Entry = {
title: string
description: string
icon: typeof IconBook
}
const products: Entry[] = [
{
title: "Components",
description: "Accessible building blocks with motion built in.",
icon: IconComponents,
},
{
title: "Blocks",
description: "Full sections you can drop into a page.",
icon: IconLayoutDashboard,
},
{
title: "Themes",
description: "Tokens for color, radius and type.",
icon: IconPalette,
},
{
title: "Templates",
description: "Starter apps ready to deploy.",
icon: IconBuildingStore,
},
]
const solutions: Entry[] = [
{
title: "Startups",
description: "Ship a polished product on day one.",
icon: IconRocket,
},
{
title: "Teams",
description: "One system shared across every app.",
icon: IconUsers,
},
{
title: "Analytics",
description: "Dashboards, tables and charts.",
icon: IconChartBar,
},
{
title: "Security",
description: "Audited for accessibility and safe defaults.",
icon: IconShieldLock,
},
{
title: "Developers",
description: "Typed APIs that read like shadcn.",
icon: IconCode,
},
{
title: "Open source",
description: "Read every line on GitHub.",
icon: IconBrandGithub,
},
]
const resources: Entry[] = [
{
title: "Documentation",
description: "Every component, example and prop.",
icon: IconBook,
},
{
title: "Support",
description: "Questions, bugs and feature requests.",
icon: IconLifebuoy,
},
]
function Links({ entries }: { entries: Entry[] }) {
return (
<ul className="grid gap-1 sm:grid-cols-2">
{entries.map((entry) => (
<li key={entry.title}>
<NavigationMenuLink href="#">
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-background ring-(length:--hairline) ring-border">
<entry.icon className="text-foreground" />
</span>
<span className="flex min-w-0 flex-col">
<span className="font-medium">{entry.title}</span>
<span className="text-muted-foreground">{entry.description}</span>
</span>
</NavigationMenuLink>
</li>
))}
</ul>
)
}
export function NavigationMenuDemo() {
return (
<div className="w-full max-w-2xl rounded-xl border px-2 py-1.5">
<NavigationMenu variant="panel" aria-label="Main">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
<NavigationMenuContent>
<Links entries={products} />
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Solutions</NavigationMenuTrigger>
<NavigationMenuContent>
<Links entries={solutions} />
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Resources</NavigationMenuTrigger>
<NavigationMenuContent>
<Links entries={resources} />
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="#">Pricing</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/navigation-menu.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/navigation-menu.tsx "use client" import * as React from "react" import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu" import { IconChevronDown } from "@tabler/icons-react" import { cva } from "class-variance-authority" import { cn } from "cn" import { SafeAreaOverlay } from "@/components/ui/safe-area" import { useSlidingHighlight } from "@/lib/motion" type NavigationMenuVariant = "dropdown" | "panel" const NavigationMenuContext = React.createContext<NavigationMenuVariant>("dropdown") type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } type NavigationMenuProps = NavigationMenuPrimitive.Root.Props & Pick< NavigationMenuPrimitive.Positioner.Props, "align" | "sideOffset" | "alignOffset" | "collisionPadding" > & { variant?: NavigationMenuVariant showSafeArea?: boolean } function NavigationMenu({ variant = "dropdown", align, sideOffset, alignOffset, collisionPadding, className, children, value: valueProp, defaultValue, onValueChange, showSafeArea = false, ref, ...props }: NavigationMenuProps) { const rootRef = React.useRef<HTMLElement | null>(null) const popupRef = React.useRef<HTMLDivElement | null>(null) const [open, setOpen] = React.useState( defaultValue != null && valueProp === undefined ) const getElements = React.useCallback(() => { const reference = rootRef.current?.querySelector( "[data-slot=navigation-menu-trigger][data-popup-open]" ) const floating = popupRef.current return reference && floating ? { reference, floating } : null }, []) const highlightRef = React.useRef<HTMLSpanElement | null>(null) useSlidingHighlight( rootRef, highlightRef, "[data-slot=navigation-menu-trigger][data-popup-open]" ) const setRef = React.useCallback( (node: HTMLElement | null) => { rootRef.current = node if (typeof ref === "function") { return ref(node as never) } if (ref) { ;(ref as React.RefObject<HTMLElement | null>).current = node } return undefined }, [ref] ) return ( <NavigationMenuContext.Provider value={variant}> <NavigationMenuPrimitive.Root ref={setRef} data-slot="navigation-menu" data-variant={variant} value={valueProp} defaultValue={defaultValue} onValueChange={(next, details) => { onValueChange?.(next, details) if (!details.isCanceled) { setOpen(next != null) } }} className={mergeClassName( cn( "group/navigation-menu relative isolate flex min-w-0 flex-1 items-center", variant === "panel" ? "w-full max-w-none" : "max-w-max justify-center" ), className )} {...props} > <span ref={highlightRef} aria-hidden="true" data-slot="navigation-menu-highlight" className="pointer-events-none absolute top-0 -z-1 rounded-md bg-accent opacity-0 transition-[transform,width,height,opacity] duration-300 ease-out-quint data-instant:transition-opacity data-visible:opacity-100 motion-reduce:transition-opacity" /> {children} <NavigationMenuPositioner align={align ?? (variant === "panel" ? "center" : "start")} sideOffset={sideOffset} alignOffset={alignOffset} collisionPadding={collisionPadding} anchor={variant === "panel" ? rootRef : undefined} popupRef={popupRef} /> {showSafeArea && (valueProp === undefined ? open : valueProp != null) ? ( <SafeAreaOverlay getElements={getElements} /> ) : null} </NavigationMenuPrimitive.Root> </NavigationMenuContext.Provider> ) } function NavigationMenuList({ className, ...props }: NavigationMenuPrimitive.List.Props) { return ( <NavigationMenuPrimitive.List data-slot="navigation-menu-list" className={mergeClassName( "-my-1 flex min-w-0 flex-1 [scrollbar-width:none] list-none items-center gap-0.5 overflow-x-auto overscroll-x-none py-1 group-data-[orientation=vertical]/navigation-menu:flex-col group-data-[orientation=vertical]/navigation-menu:items-stretch", className )} {...props} /> ) } function NavigationMenuItem({ className, ...props }: NavigationMenuPrimitive.Item.Props) { return ( <NavigationMenuPrimitive.Item data-slot="navigation-menu-item" className={mergeClassName("relative", className)} {...props} /> ) } const navigationMenuTriggerStyle = cva( "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center gap-1 rounded-md px-3 text-sm font-medium whitespace-nowrap outline-none select-none focus-visible:ring-3 focus-visible:inset-ring-(length:--hairline) focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden data-popup-open:text-accent-foreground data-[active]:text-foreground pointer-coarse:h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [@media(hover:hover)]:group-not-has-[[data-popup-open]]/navigation-menu:hover:bg-muted/70" ) function NavigationMenuTrigger({ className, children, ...props }: NavigationMenuPrimitive.Trigger.Props) { return ( <NavigationMenuPrimitive.Trigger data-slot="navigation-menu-trigger" className={mergeClassName(navigationMenuTriggerStyle(), className)} {...props} > {children} <IconChevronDown aria-hidden="true" data-slot="navigation-menu-trigger-icon" className="size-3.5 text-muted-foreground transition-transform duration-300 ease-out-quint group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:text-accent-foreground motion-reduce:transition-none" /> </NavigationMenuPrimitive.Trigger> ) } const contentSlide = "transition-[opacity,translate,filter] duration-300 ease-out-quint data-ending-style:opacity-0 data-starting-style:opacity-0 motion-safe:data-ending-style:blur-[2px] motion-safe:data-starting-style:blur-[2px] motion-safe:data-starting-style:data-[activation-direction=left]:-translate-x-12 motion-safe:data-starting-style:data-[activation-direction=right]:translate-x-12 motion-safe:data-ending-style:data-[activation-direction=left]:translate-x-12 motion-safe:data-ending-style:data-[activation-direction=right]:-translate-x-12 motion-safe:data-starting-style:data-[activation-direction=up]:-translate-y-6 motion-safe:data-starting-style:data-[activation-direction=down]:translate-y-6 motion-safe:data-ending-style:data-[activation-direction=up]:translate-y-6 motion-safe:data-ending-style:data-[activation-direction=down]:-translate-y-6 motion-reduce:transition-opacity" const linkSelector = "[data-slot=navigation-menu-link]" const arrowKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"]) function nearestLink(from: HTMLElement, links: HTMLElement[], key: string) { const origin = from.getBoundingClientRect() const ox = origin.left + origin.width / 2 const oy = origin.top + origin.height / 2 const vertical = key === "ArrowDown" || key === "ArrowUp" let best: HTMLElement | null = null let bestScore = Infinity for (const link of links) { if (link === from) { continue } const rect = link.getBoundingClientRect() const dx = rect.left + rect.width / 2 - ox const dy = rect.top + rect.height / 2 - oy const ahead = key === "ArrowDown" ? dy > 1 : key === "ArrowUp" ? dy < -1 : key === "ArrowRight" ? dx > 1 : dx < -1 if (!ahead) { continue } const score = vertical ? Math.abs(dy) + Math.abs(dx) * 2 : Math.abs(dx) + Math.abs(dy) * 2 if (score < bestScore) { bestScore = score best = link } } return best } function NavigationMenuContent({ className, children, onPointerOver, onPointerLeave, onFocus, onKeyDown, ref, ...props }: NavigationMenuPrimitive.Content.Props) { const variant = React.useContext(NavigationMenuContext) const contentRef = React.useRef<HTMLDivElement | null>(null) const highlightRef = React.useRef<HTMLSpanElement | null>(null) const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>( null ) const mountedRef = React.useMemo( () => ({ current: contentNode }), [contentNode] ) useSlidingHighlight( mountedRef, highlightRef, "[data-slot=navigation-menu-link][data-highlighted]", "data-highlighted" ) const setRef = React.useCallback( (node: HTMLDivElement | null) => { contentRef.current = node setContentNode(node) if (typeof ref === "function") { return ref(node) } if (ref) { ref.current = node } return undefined }, [ref] ) const highlight = (link: Element | null) => { const content = contentRef.current if (!content) { return } for (const current of content.querySelectorAll("[data-highlighted]")) { if (current !== link) { current.removeAttribute("data-highlighted") } } link?.setAttribute("data-highlighted", "") } const focused = () => contentRef.current?.querySelector(`${linkSelector}:focus-visible`) ?? null return ( <NavigationMenuPrimitive.Content ref={setRef} data-slot="navigation-menu-content" data-variant={variant} className={mergeClassName( cn( "relative isolate", "h-full p-2 [--navigation-menu-link-radius:max(calc(var(--radius-sm)*0.5),calc(var(--radius-xl)-var(--spacing)*2))]", variant === "panel" ? "w-(--anchor-width) p-3" : "w-max", contentSlide ), className )} onPointerOver={(event) => { onPointerOver?.(event) if (event.pointerType !== "touch") { highlight( (event.target as Element).closest(linkSelector) ?? focused() ) } }} onPointerLeave={(event) => { onPointerLeave?.(event) highlight(focused()) }} onFocus={(event) => { onFocus?.(event) const target = event.target as Element if (target.matches(linkSelector) && target.matches(":focus-visible")) { highlight(target) } }} onKeyDown={(event) => { onKeyDown?.(event) const content = contentRef.current const target = event.target as HTMLElement if ( event.defaultPrevented || !content || !arrowKeys.has(event.key) || !target.matches(linkSelector) ) { return } const links = Array.from( content.querySelectorAll<HTMLElement>(linkSelector) ).filter((link) => link.getClientRects().length > 0) const next = nearestLink(target, links, event.key) if (next) { event.preventDefault() event.preventBaseUIHandler?.() next.focus() highlight(next) } }} {...props} > <span ref={highlightRef} aria-hidden="true" data-slot="navigation-menu-link-highlight" className="pointer-events-none absolute top-0 -z-1 rounded-(--navigation-menu-link-radius) bg-muted opacity-0 transition-[transform,width,height,opacity] duration-200 ease-out-quint data-instant:transition-opacity data-visible:opacity-100 motion-reduce:transition-opacity" /> {children} </NavigationMenuPrimitive.Content> ) } function NavigationMenuPositioner({ className, side = "bottom", sideOffset = 8, align = "start", alignOffset = 0, collisionPadding = 8, anchor, popupRef, ...props }: NavigationMenuPrimitive.Positioner.Props & { popupRef?: React.Ref<HTMLDivElement> }) { const variant = React.useContext(NavigationMenuContext) return ( <NavigationMenuPrimitive.Portal> <NavigationMenuPrimitive.Positioner data-slot="navigation-menu-positioner" side={side} sideOffset={sideOffset} align={align} alignOffset={alignOffset} collisionPadding={collisionPadding} anchor={anchor} className={mergeClassName( cn( "isolate z-50 h-(--positioner-height) max-w-(--available-width) transition-[top,left,right,bottom] duration-300 ease-out-quint data-instant:transition-none motion-reduce:transition-none", variant === "panel" ? "w-(--anchor-width)" : "w-(--positioner-width)" ), className )} {...props} > <NavigationMenuPrimitive.Popup ref={popupRef} data-slot="navigation-menu-popup" className={cn( "relative h-(--popup-height) max-h-(--available-height) origin-(--transform-origin) overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-md ring-(length:--hairline) ring-foreground/10 transition-[opacity,scale,width,height] duration-300 ease-out-quint outline-none focus-visible:outline-hidden data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:opacity-0 motion-safe:data-ending-style:scale-97 motion-safe:data-starting-style:scale-97 motion-reduce:transition-opacity forced-colors:border", variant === "panel" ? "w-full" : "w-(--popup-width)" )} > <NavigationMenuPrimitive.Viewport data-slot="navigation-menu-viewport" className="relative size-full overflow-x-hidden overflow-y-auto overscroll-contain" /> </NavigationMenuPrimitive.Popup> </NavigationMenuPrimitive.Positioner> </NavigationMenuPrimitive.Portal> ) } function NavigationMenuLink({ className, ...props }: NavigationMenuPrimitive.Link.Props) { return ( <NavigationMenuPrimitive.Link data-slot="navigation-menu-link" className={mergeClassName( "flex min-w-0 items-center gap-2 rounded-(--navigation-menu-link-radius,var(--radius-md)) p-2 text-sm transition-colors duration-150 outline-none not-in-data-[slot=navigation-menu-content]:h-9 not-in-data-[slot=navigation-menu-content]:px-3 not-in-data-[slot=navigation-menu-content]:py-0 not-in-data-[slot=navigation-menu-content]:font-medium focus-visible:ring-3 focus-visible:inset-ring-(length:--hairline) focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden data-[active]:bg-muted/60 motion-reduce:transition-none pointer-coarse:not-in-data-[slot=navigation-menu-content]:h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [@media(hover:hover)]:not-in-data-[slot=navigation-menu-content]:hover:bg-muted", className )} {...props} /> ) } export { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuPositioner, NavigationMenuTrigger, navigationMenuTriggerStyle, } export type { NavigationMenuProps, NavigationMenuVariant }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu"<NavigationMenu variant="panel" aria-label="Main">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
<NavigationMenuContent>
<NavigationMenuLink href="/components">Components</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/pricing">Pricing</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>With variant="panel", one card spans the whole bar. Moving between triggers keeps it open and in place: only the content slides toward the direction you moved, and the height eases to fit. Nothing closes, reopens or jumps.
NavigationMenu
└── NavigationMenuList
├── NavigationMenuItem
│ ├── NavigationMenuTrigger
│ └── NavigationMenuContent
│ └── NavigationMenuLink
└── NavigationMenuItem
└── NavigationMenuLinkDropdown#
The default dropdown variant opens a card under each trigger. Moving to the next trigger glides the same card over and resizes it to the new content.
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu"
const guides = [
{ title: "Installation", description: "Add HextaUI to a Next.js app." },
{ title: "Theming", description: "Colors, radius and dark mode." },
{ title: "Motion", description: "Durations, easing and reduced motion." },
]
const components = [
"Accordion",
"Button",
"Dialog",
"Menubar",
"Table",
"Tooltip",
]
export function NavigationMenuDropdown() {
return (
<NavigationMenu aria-label="Docs">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Guides</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="flex w-72 flex-col gap-1">
{guides.map((guide) => (
<li key={guide.title}>
<NavigationMenuLink href="#">
<span className="flex flex-col">
<span className="font-medium">{guide.title}</span>
<span className="text-muted-foreground">
{guide.description}
</span>
</span>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Components</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-80 grid-cols-2 gap-1">
{components.map((name) => (
<li key={name}>
<NavigationMenuLink href="#">{name}</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="#">Blog</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)
}Current page#
Links in the bar look like triggers. Mark the current page with active, which also sets aria-current="page".
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
} from "@/components/ui/navigation-menu"
const links = ["Overview", "Activity", "Settings"]
export function NavigationMenuActive() {
return (
<NavigationMenu aria-label="Project">
<NavigationMenuList>
{links.map((link) => (
<NavigationMenuItem key={link}>
<NavigationMenuLink href="#" active={link === "Activity"}>
{link}
</NavigationMenuLink>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
)
}Right to left#
Content slides follow the direction you move, which flips in right-to-left layouts.
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu"
const menus = [
{ label: "المنتجات", links: ["المكونات", "القوالب", "السمات"] },
{ label: "الموارد", links: ["التوثيق", "الدعم"] },
]
export function NavigationMenuRtl() {
return (
<div dir="rtl" className="w-full max-w-md rounded-xl border px-2 py-1.5">
<NavigationMenu variant="panel" aria-label="الرئيسية">
<NavigationMenuList>
{menus.map((menu) => (
<NavigationMenuItem key={menu.label}>
<NavigationMenuTrigger>{menu.label}</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid grid-cols-2 gap-1">
{menu.links.map((link) => (
<li key={link}>
<NavigationMenuLink href="#">{link}</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</div>
)
}| Key | Action |
|---|---|
| Tab | Moves through the bar, and from an open trigger into its content. |
| EnterSpace | Opens or closes the focused trigger's menu. |
| ↑↓ | Moves between links in the open content. |
| ←→ | Moves between triggers in the bar. |
| Esc | Closes the menu and returns focus to its trigger. |
- The root renders a
<nav>. Give it anaria-labelwhen the page has more than one. - Triggers are buttons that report whether their menu is open, and focus returns to them when the menu closes.
- The content slide and the sliding highlight turn into fades when reduced motion is on.
| Prop | Type | Default |
|---|---|---|
variantdropdown opens under each trigger. panel opens one full-width card under the bar that stays put while content slides. | "dropdown" | "panel" | "dropdown" |
showSafeAreaDraws the safe area that keeps a menu open while the pointer heads into it. For debugging and demos. | boolean | false |
align | "start" | "center" | "end" | "start", or "center" for panel |
sideOffset | number | 8 |
value | any | – |
defaultValue | any | – |
onValueChange | (value, details) => void | – |
delayWait before opening on hover, in ms. | number | 50 |
closeDelayWait before closing after the pointer leaves, in ms. | number | 50 |
orientation | "horizontal" | "vertical" | "horizontal" |
| Attribute | Description |
|---|---|
data-slot="navigation-menu" | The nav, with data-variant. |
data-slot="navigation-menu-highlight" | The background that slides between open triggers. |
data-slot="navigation-menu-popup" | The card. Its height eases through --popup-height. |
| Attribute | Description |
|---|---|
data-slot="navigation-menu-trigger" | Target triggers in CSS. |
data-popup-open | Present while its menu is open. The chevron flips. |
| Attribute | Description |
|---|---|
data-slot="navigation-menu-content" | A menu's content, with data-variant. |
data-activation-direction | "left", "right", "up" or "down": where the previous trigger was. Drives the slide. |
--navigation-menu-link-radius | Link corner radius, concentric with the card. |
| Prop | Type | Default |
|---|---|---|
activeMarks the current page. | boolean | false |
render | ReactElement | (props, state) => ReactElement | <a> |
| Attribute | Description |
|---|---|
data-slot="navigation-menu-link" | Target links in CSS. In the bar they size themselves like triggers. |
data-active | Present on the current page. |