Navigation Menu
A horizontal navigation bar with click-triggered link panels.
New1'use client';23import { NavigationMenu } from '@/components/ui/navigation-menu';4import { CodeIcon } from '@/components/ui/code';5import { PaletteIcon } from '@/components/ui/palette';6import { PuzzleIcon } from '@/components/ui/puzzle';78const products = [9 {10 icon: PuzzleIcon,11 title: 'Components',12 description: 'Accessible React primitives, ready to copy.',13 href: '#',14 },15 {16 icon: PaletteIcon,17 title: 'Themes',18 description: 'Design tokens for light and dark out of the box.',19 href: '#',20 },21 {22 icon: CodeIcon,23 title: 'CLI',24 description: 'Pull source files straight into your project.',25 href: '#',26 },27];2829export function Default() {30 return (31 <NavigationMenu className="min-h-64 items-start">32 <NavigationMenu.Item>33 <NavigationMenu.Trigger className="text-sm">Products</NavigationMenu.Trigger>34 <NavigationMenu.Content>35 {products.map((product) => (36 <NavigationMenu.Link37 key={product.title}38 href={product.href}39 title={product.title}40 description={product.description}41 icon={<product.icon size={16} strokeWidth={1.6} />}42 />43 ))}44 </NavigationMenu.Content>45 </NavigationMenu.Item>46 <a47 href="#docs"48 className="text-muted-foreground hover:text-foreground text-sm transition-colors"49 >50 Docs51 </a>52 <a53 href="#pricing"54 className="text-muted-foreground hover:text-foreground text-sm transition-colors"55 >56 Pricing57 </a>58 </NavigationMenu>59 );60}
Installation
Copy and paste the following code into your project.
1'use client';23import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from 'motion/react';4import * as React from 'react';5import { cn } from '../lib/cn';6import { floatingOrigin, floatingVariants, reveal } from '../lib/motion';7import { assignRef, useLayer } from '../lib/overlay';89type IconProps = React.SVGProps<SVGSVGElement> & {10 size?: number | string;11};1213function ChevronDownIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {14 return (15 <svg16 xmlns="http://www.w3.org/2000/svg"17 width={size}18 height={size}19 viewBox="0 0 24 24"20 fill="none"21 stroke="currentColor"22 strokeWidth={strokeWidth}23 strokeLinecap="round"24 strokeLinejoin="round"25 aria-hidden="true"26 {...props}27 >28 <path d="m6 9 6 6 6-6" />29 </svg>30 );31}3233const MENU_STYLE = { willChange: 'opacity, transform, filter' } as const;3435const VIEWPORT_PADDING = 8;3637function useViewportShift(ref: React.RefObject<HTMLElement | null>, open: boolean) {38 const [shift, setShift] = React.useState(0);3940 React.useLayoutEffect(() => {41 if (!open) return;42 const measure = () => {43 const el = ref.current;44 if (!el) return;45 const { transform, translate } = el.style;46 el.style.transform = 'none';47 el.style.translate = '';48 const rect = el.getBoundingClientRect();49 el.style.transform = transform;50 el.style.translate = translate;51 const viewport = document.documentElement.clientWidth || window.innerWidth;52 if (!rect.width || !viewport) return;53 let next = 0;54 if (rect.right > viewport - VIEWPORT_PADDING) next = viewport - VIEWPORT_PADDING - rect.right;55 if (rect.left + next < VIEWPORT_PADDING) next = VIEWPORT_PADDING - rect.left;56 setShift(Math.round(next));57 };58 measure();59 window.addEventListener('resize', measure);60 return () => window.removeEventListener('resize', measure);61 }, [open, ref]);6263 return shift;64}6566interface NavigationMenuItemContextType {67 open: boolean;68 setOpen: (open: boolean) => void;69 id: string;70 triggerRef: React.RefObject<HTMLButtonElement | null>;71}7273interface NavigationMenuRootContextType {74 openItem: string | null;75 setOpenItem: React.Dispatch<React.SetStateAction<string | null>>;76}7778const NavigationMenuRootContext = React.createContext<NavigationMenuRootContextType | null>(null);7980const NavigationMenuItemContext = React.createContext<NavigationMenuItemContextType | undefined>(81 undefined,82);8384const useNavigationMenuItem = () => {85 const context = React.use(NavigationMenuItemContext);86 if (!context) {87 throw new Error('NavigationMenu parts must be used within a NavigationMenu.Item');88 }89 return context;90};9192const NavigationMenuRoot = ({93 className,94 ref,95 ...props96}: React.HTMLAttributes<HTMLElement> & { ref?: React.Ref<HTMLElement> }) => {97 const [openItem, setOpenItem] = React.useState<string | null>(null);98 const contextValue = React.useMemo(() => ({ openItem, setOpenItem }), [openItem]);99100 return (101 <NavigationMenuRootContext value={contextValue}>102 <nav ref={ref} className={cn('flex items-center gap-6', className)} {...props} />103 </NavigationMenuRootContext>104 );105};106NavigationMenuRoot.displayName = 'NavigationMenu';107108const NavigationMenuItem = ({109 className,110 children,111 onBlur,112 ref,113 ...props114}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => {115 const root = React.use(NavigationMenuRootContext);116 const [localOpen, setLocalOpen] = React.useState(false);117 const id = React.useId();118 const containerRef = React.useRef<HTMLDivElement>(null);119 const triggerRef = React.useRef<HTMLButtonElement>(null);120 const open = root ? root.openItem === id : localOpen;121 const layer = useLayer(open, false, containerRef);122 const setRootItem = root?.setOpenItem;123124 const setOpen = React.useCallback(125 (next: boolean) => {126 if (!setRootItem) {127 setLocalOpen(next);128 return;129 }130 setRootItem((current) => (next ? id : current === id ? null : current));131 },132 [setRootItem, id],133 );134135 React.useEffect(() => {136 if (!open) return;137138 const handlePointerDownOutside = (e: PointerEvent) => {139 const container = containerRef.current;140 if (!container || container.contains(e.target as Node)) return;141 if (layer.isInsideUpperLayer(e.target)) return;142 setOpen(false);143 };144145 const handleEscape = (e: KeyboardEvent) => {146 if (e.key !== 'Escape' || e.defaultPrevented || !layer.isTopmost()) return;147 const active = document.activeElement;148 const focusInside = !!active && !!containerRef.current?.contains(active);149 if (!focusInside && active && active !== document.body) return;150 e.preventDefault();151 setOpen(false);152 if (focusInside) triggerRef.current?.focus();153 };154155 document.addEventListener('pointerdown', handlePointerDownOutside);156 document.addEventListener('keydown', handleEscape);157 return () => {158 document.removeEventListener('pointerdown', handlePointerDownOutside);159 document.removeEventListener('keydown', handleEscape);160 };161 }, [open, setOpen, layer]);162163 const handleBlur = React.useCallback(164 (e: React.FocusEvent<HTMLDivElement>) => {165 onBlur?.(e);166 const next = e.relatedTarget;167 if (!open || !(next instanceof Node)) return;168 if (e.currentTarget.contains(next) || layer.isInsideUpperLayer(next)) return;169 setOpen(false);170 },171 [onBlur, open, setOpen, layer],172 );173174 const contextValue = React.useMemo(175 () => ({ open, setOpen, id, triggerRef }),176 [open, setOpen, id],177 );178179 const mergedRef = React.useCallback(180 (node: HTMLDivElement | null) => {181 containerRef.current = node;182 assignRef(ref, node);183 },184 [ref],185 );186187 return (188 <NavigationMenuItemContext value={contextValue}>189 <div ref={mergedRef} onBlur={handleBlur} className={cn('relative', className)} {...props}>190 {children}191 </div>192 </NavigationMenuItemContext>193 );194};195NavigationMenuItem.displayName = 'NavigationMenuItem';196197const NavigationMenuTrigger = ({198 className,199 children,200 onClick,201 ref,202 ...props203}: React.ButtonHTMLAttributes<HTMLButtonElement> & { ref?: React.Ref<HTMLButtonElement> }) => {204 const { open, setOpen, id, triggerRef } = useNavigationMenuItem();205206 const mergedRef = React.useCallback(207 (node: HTMLButtonElement | null) => {208 triggerRef.current = node;209 if (typeof ref === 'function') ref(node);210 else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;211 },212 [ref, triggerRef],213 );214215 return (216 <button217 type="button"218 ref={mergedRef}219 aria-expanded={open}220 aria-controls={open ? id : undefined}221 onClick={(e) => {222 setOpen(!open);223 onClick?.(e);224 }}225 className={cn(226 'text-muted-foreground hover:text-foreground focus-visible:ring-ring ring-offset-background flex min-h-9 cursor-pointer items-center gap-1 rounded-md px-2 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',227 open && 'text-foreground',228 className,229 )}230 {...props}231 >232 {children}233 <ChevronDownIcon234 size={13}235 aria-hidden="true"236 className={cn('transition-transform duration-200', open && 'rotate-180')}237 />238 </button>239 );240};241NavigationMenuTrigger.displayName = 'NavigationMenuTrigger';242243const NavigationMenuContent = ({ className, children, ...props }: HTMLMotionProps<'div'>) => {244 const { open, id } = useNavigationMenuItem();245 const shouldReduceMotion = useReducedMotion();246 const contentRef = React.useRef<HTMLDivElement>(null);247 const shift = useViewportShift(contentRef, open);248 const style = React.useMemo(249 () => ({ ...MENU_STYLE, translate: shift ? `${shift}px` : undefined }),250 [shift],251 );252253 return (254 <AnimatePresence>255 {open && (256 <motion.div257 ref={contentRef}258 id={id}259 variants={shouldReduceMotion ? reveal : floatingVariants.bottom}260 initial="hidden"261 animate="visible"262 exit="exit"263 style={style}264 className={cn(265 'bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-2 w-72 max-w-[calc(100vw-2rem)] rounded-md border p-1 shadow-md',266 floatingOrigin.bottom,267 className,268 )}269 {...props}270 >271 <span272 aria-hidden="true"273 className="border-border bg-popover absolute -top-[5.5px] left-5 size-2.5 rotate-45 rounded-[1px] border-t border-l"274 />275 {children as React.ReactNode}276 </motion.div>277 )}278 </AnimatePresence>279 );280};281NavigationMenuContent.displayName = 'NavigationMenuContent';282283interface NavigationMenuLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {284 asChild?: boolean;285 icon?: React.ReactNode;286 title: string;287 description?: string;288 badge?: React.ReactNode;289}290291const NavigationMenuLink = ({292 asChild,293 icon,294 title,295 description,296 badge,297 className,298 children,299 href,300 onClick,301 ...props302}: NavigationMenuLinkProps) => {303 const { setOpen } = useNavigationMenuItem();304305 const rowClassName = cn(306 'group/navlink hover:bg-muted focus-visible:ring-ring flex items-start gap-3 rounded-md px-2.5 py-2.5 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset',307 className,308 );309310 const content = (311 <>312 {icon && (313 <span className="border-border bg-background text-muted-foreground group-hover/navlink:text-foreground flex size-9 shrink-0 items-center justify-center rounded-md border transition-colors">314 {icon}315 </span>316 )}317 <span className="min-w-0">318 <span className="flex items-center gap-2">319 <span className="text-foreground text-sm font-medium">{title}</span>320 {badge}321 </span>322 {description && (323 <span className="text-muted-foreground mt-0.5 block truncate text-xs">{description}</span>324 )}325 </span>326 </>327 );328329 if (asChild && React.isValidElement(children)) {330 const child = children as React.ReactElement<Record<string, unknown>>;331 const childOnClick = child.props.onClick as ((e: React.MouseEvent) => void) | undefined;332333 return React.cloneElement(334 child,335 {336 className: cn(rowClassName, child.props.className as string | undefined),337 onClick: (e: React.MouseEvent) => {338 childOnClick?.(e);339 setOpen(false);340 },341 },342 content,343 );344 }345346 return (347 <a348 href={href}349 className={rowClassName}350 onClick={(e) => {351 onClick?.(e as React.MouseEvent<HTMLAnchorElement>);352 setOpen(false);353 }}354 {...props}355 >356 {content}357 </a>358 );359};360NavigationMenuLink.displayName = 'NavigationMenuLink';361362const NavigationMenu = Object.assign(NavigationMenuRoot, {363 Item: NavigationMenuItem,364 Trigger: NavigationMenuTrigger,365 Content: NavigationMenuContent,366 Link: NavigationMenuLink,367});368369export { NavigationMenu };370export type { NavigationMenuLinkProps };
Update the import paths to match your project structure.
Anatomy
1import { NavigationMenu } from '@/components/ui/navigation-menu';
1<NavigationMenu>2 <NavigationMenu.Item>3 <NavigationMenu.Trigger>Products</NavigationMenu.Trigger>4 <NavigationMenu.Content>5 <NavigationMenu.Link6 href="/components"7 title="Components"8 description="Accessible React primitives, ready to copy."9 />10 </NavigationMenu.Content>11 </NavigationMenu.Item>12 <a href="/docs">Docs</a>13</NavigationMenu>
Composition
Use the following composition to build a
NavigationMenu:NavigationMenuโโโ NavigationMenu.Itemโ โโโ NavigationMenu.Triggerโ โโโ NavigationMenu.Contentโ โโโ NavigationMenu.Linkโโโ (plain links)
The root renders a
<nav> and lays its children out in a row, so plain anchors
and router links live side by side with dropdown items. Only one Item is
open at a time: opening another one closes the first. Items open on click,
never on hover, so panels only show up when the reader asks for them, and they
close on Escape, on an outside click, when focus tabs out of the item, or
after following a link. The panel carries a small caret pointing back at the
trigger that opened it.Router links
NavigationMenu.Link renders an <a> by default. To keep client-side
navigation (or locale-aware routing), pass asChild with your framework's
Link as the only child: the row content is injected into it and the menu
still closes after navigating:1<NavigationMenu.Link2 asChild3 title="Components"4 description="Accessible React primitives."5 icon={<HugeiconsIcon icon={PuzzleIcon} size={16} />}6>7 <Link href="/docs/elements/ui" />8</NavigationMenu.Link>
Variants
With badges
Rows accept a
badge slot next to the title, and anything else you drop into
Content renders as-is, like this non-interactive "coming soon" row:1'use client';23import { Badge } from '@/components/ui/badge';4import { NavigationMenu } from '@/components/ui/navigation-menu';5import { BookIcon } from '@/components/ui/book';6import { PuzzleIcon } from '@/components/ui/puzzle';7import { ServerIcon } from '@/components/ui/server';89const resources = [10 {11 icon: PuzzleIcon,12 title: 'Components',13 description: 'Free and open source, forever.',14 badge: { variant: 'success', label: 'Open source' },15 },16 {17 icon: BookIcon,18 title: 'Changelog',19 description: '12 components shipped since v2.13.',20 badge: { variant: 'info', label: 'New' },21 },22 {23 icon: ServerIcon,24 title: 'MCP server',25 description: 'Pull component source into your editor.',26 badge: { variant: 'warning', label: 'Beta' },27 },28] as const;2930export function Badges() {31 return (32 <NavigationMenu className="min-h-64 items-start">33 <NavigationMenu.Item>34 <NavigationMenu.Trigger className="text-sm">Resources</NavigationMenu.Trigger>35 <NavigationMenu.Content>36 {resources.map((resource) => (37 <NavigationMenu.Link38 key={resource.title}39 href="#"40 title={resource.title}41 description={resource.description}42 badge={<Badge variant={resource.badge.variant}>{resource.badge.label}</Badge>}43 icon={<resource.icon size={16} strokeWidth={1.6} />}44 />45 ))}46 </NavigationMenu.Content>47 </NavigationMenu.Item>48 </NavigationMenu>49 );50}
API Reference
NavigationMenu
Renders a
<nav> element. Accepts every HTMLElement attribute.| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
NavigationMenu.Item
Wraps one trigger + panel pair and owns its open state.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
NavigationMenu.Trigger
A button with a chevron that rotates while the panel is open.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
NavigationMenu.Content
The floating panel, anchored below the item and animated on open/close.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
NavigationMenu.Link
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | - | Row title (required) |
description | string | - | Muted single-line description |
icon | React.ReactNode | - | Rendered inside the leading icon box |
badge | React.ReactNode | - | Rendered next to the title |
asChild | boolean | false | Inject the row into a child routing <Link> |
href | string | - | Anchor target when not using asChild |
className | string | - | Additional CSS classes |
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.