Navigation Menu
A top nav whose triggers share one dropdown that morphs to fit each menu, with a featured tile and link rows that lift into chips.
A row of triggers over a single shared panel that springs to each menu's exact size as you sweep across it. Each menu can lead with a featured tile and a column of rows that lift into top-lit chips on hover or focus. Built with Motion.
Sweep across Products, Solutions, and Resources to watch the one panel morph between menus. It's keyboard-operable: tab to a trigger to open it, tab into the rows, and press Esc to close.
Installation
Complete the shared Setup first, then copy the component into
components/ui/navigation-menu.tsx.
bun add motion'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion } from 'motion/react';import { ChevronsUpDown } from 'lucide-react';import { cn } from '@/lib/cn';const SPRING = { type: 'spring' as const, duration: 0.5, bounce: 0.16 };export type NavMenuLink = { label: string; description?: string; href?: string;};export type NavFeatured = { icon: React.ReactNode; title: string; description: string; href?: string;};export type NavMenuItem = | { label: string; href: string } | { label: string; href?: never; featured?: NavFeatured; links: NavMenuLink[]; };function isTrigger( item: NavMenuItem,): item is Extract<NavMenuItem, { links: NavMenuLink[] }> { return 'links' in item && Array.isArray(item.links);}export type NavigationMenuProps = { items: NavMenuItem[]; accent?: string; className?: string;};export function NavigationMenu({ items, accent = '#f0883e', className,}: NavigationMenuProps) { const reduce = useReducedMotion(); const [active, setActive] = React.useState<number | null>(null); const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null); const clearClose = React.useCallback(() => { if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } }, []); const scheduleClose = React.useCallback(() => { clearClose(); closeTimer.current = setTimeout(() => setActive(null), 120); }, [clearClose]); const open = React.useCallback( (i: number | null) => { clearClose(); setActive(i); }, [clearClose], ); React.useEffect(() => clearClose, [clearClose]); const [size, setSize] = React.useState<{ width: number; height: number } | null>( null, ); const observer = React.useRef<ResizeObserver | null>(null); const measureRef = React.useCallback((node: HTMLDivElement | null) => { observer.current?.disconnect(); if (node) { const measure = () => setSize({ width: node.offsetWidth, height: node.offsetHeight }); measure(); observer.current = new ResizeObserver(measure); observer.current.observe(node); } }, []); const activeItem = active != null ? items[active] : null; const activeMenu = activeItem && isTrigger(activeItem) ? activeItem : null; const vars = { '--nav-accent': accent } as React.CSSProperties; return ( <nav style={vars} onPointerLeave={scheduleClose} onPointerEnter={clearClose} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setActive(null); }} onKeyDown={(e) => { if (e.key === 'Escape') setActive(null); }} className={cn('relative inline-block text-left', className)} > <ul className="flex items-center gap-1"> {items.map((item, i) => { const trigger = isTrigger(item); const isOpen = active === i; return ( <li key={item.label}> <motion.a href={trigger ? undefined : item.href} role={trigger ? 'button' : undefined} tabIndex={0} aria-expanded={trigger ? isOpen : undefined} aria-haspopup={trigger ? 'menu' : undefined} whileTap={reduce ? undefined : { scale: 0.97 }} onPointerEnter={() => open(trigger ? i : null)} onFocus={() => open(trigger ? i : null)} onClick={() => trigger && setActive(isOpen ? null : i)} className={cn( 'group relative flex cursor-pointer select-none items-center gap-1.5', 'rounded-[10px] px-3 py-1.5 text-[13px] font-medium tracking-[-0.01em]', 'outline-none transition-colors duration-150', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--nav-accent)]', isOpen ? 'text-neutral-900 dark:text-white' : 'text-neutral-500 hover:text-neutral-900 dark:text-[#929292] dark:hover:text-white', )} > <span aria-hidden className={cn( 'bloom-edge pointer-events-none absolute inset-0 rounded-[10px] opacity-0 transition-opacity duration-150', 'bg-gradient-to-b from-white to-[#f4f4f5]', 'dark:from-[#242424] dark:to-[#1c1c1c]', 'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.9),0_1px_2px_0_rgba(0,0,0,0.06),0_3px_8px_-3px_rgba(0,0,0,0.12)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.08),0_1px_2px_0_rgba(0,0,0,0.4),0_3px_8px_-2px_rgba(0,0,0,0.6)]', 'group-hover:opacity-100', isOpen && 'opacity-100', )} /> <span className="relative">{item.label}</span> {trigger && ( <ChevronsUpDown aria-hidden className="relative size-3.5 opacity-60" strokeWidth={2} /> )} </motion.a> </li> ); })} </ul> <AnimatePresence> {activeMenu && ( <motion.div key="panel" initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: 'blur(4px)' }} animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }} exit={ reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: 'blur(4px)' } } transition={{ duration: 0.16, ease: [0.23, 1, 0.32, 1] }} className="absolute left-0 top-full z-50 pt-3" > <motion.div animate={size ?? undefined} transition={reduce ? { duration: 0.18 } : SPRING} className={cn( 'bloom-edge overflow-hidden rounded-[14px]', 'bg-gradient-to-b from-white to-[#f4f4f5]', 'dark:from-[#1d1d1d] dark:to-[#161716]', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_14px_36px_-12px_rgba(0,0,0,0.18)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_18px_44px_-12px_rgba(0,0,0,0.7)]', )} > <div ref={measureRef} className="w-max"> <AnimatePresence mode="popLayout" initial={false}> <motion.div key={active} initial={reduce ? { opacity: 0 } : { opacity: 0, filter: 'blur(4px)' }} animate={{ opacity: 1, filter: 'blur(0px)' }} exit={reduce ? { opacity: 0 } : { opacity: 0, filter: 'blur(4px)' }} transition={{ duration: 0.16, ease: [0.23, 1, 0.32, 1] }} className="flex gap-2 p-2" > {activeMenu.featured && ( <FeaturedTile featured={activeMenu.featured} /> )} <ul className={cn( 'flex flex-col gap-0.5', activeMenu.featured ? 'w-[300px]' : 'w-[260px]', )} > {activeMenu.links.map((link) => ( <li key={link.label}> <MenuRow link={link} /> </li> ))} </ul> </motion.div> </AnimatePresence> </div> </motion.div> </motion.div> )} </AnimatePresence> </nav> );}function FeaturedTile({ featured }: { featured: NavFeatured }) { return ( <a href={featured.href} className={cn( 'group relative flex w-[220px] flex-col rounded-[11px] p-4 outline-none', 'bg-black/[0.02] dark:bg-black/20', 'shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]', 'transition-[box-shadow,transform] duration-150 active:scale-[0.99]', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--nav-accent)]', )} > <span aria-hidden className={cn( 'bloom-edge flex size-11 items-center justify-center self-start rounded-[10px]', 'bg-gradient-to-b from-white to-[#f1f1f2] text-neutral-900', 'dark:from-[#272727] dark:to-[#1d1d1d] dark:text-white', 'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.9),0_1px_2px_0_rgba(0,0,0,0.08)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.08),0_1px_2px_0_rgba(0,0,0,0.5)]', 'mb-auto [&_svg]:size-[22px]', )} > {featured.icon} </span> <div className="mt-6"> <p className="text-[14px] font-medium tracking-[-0.01em] text-neutral-900 dark:text-white"> {featured.title} </p> <p className="mt-1 text-[12px] leading-snug text-neutral-500 dark:text-[#929292]"> {featured.description} </p> </div> </a> );}function MenuRow({ link }: { link: NavMenuLink }) { return ( <a href={link.href} className={cn( 'group/row relative block rounded-[10px] px-3 py-2.5 outline-none', 'transition-[box-shadow,background] duration-150', 'hover:bg-white dark:hover:bg-[#242424]', 'hover:shadow-[0_0_0_1px_rgba(0,0,0,0.06),inset_0_1px_0_0_rgba(255,255,255,0.9),0_2px_5px_-1px_rgba(0,0,0,0.12)]', 'dark:hover:shadow-[0_0_0_1px_rgba(255,255,255,0.08),inset_0_1px_0_0_rgba(255,255,255,0.08),0_3px_8px_-2px_rgba(0,0,0,0.6)]', 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:[outline-color:var(--nav-accent)]', )} > <p className="text-[13px] font-medium tracking-[-0.01em] text-neutral-700 transition-colors group-hover/row:text-neutral-900 dark:text-[#d4d4d4] dark:group-hover/row:text-white"> {link.label} </p> {link.description && ( <p className="mt-0.5 text-[12px] leading-snug text-neutral-500 dark:text-[#929292]"> {link.description} </p> )} </a> );}Usage
import { Network } from 'lucide-react';
import { NavigationMenu } from '@/components/ui/navigation-menu';
export default function Example() {
return (
<NavigationMenu
items={[
{
label: 'Products',
featured: {
icon: <Network strokeWidth={1.75} />,
title: 'One platform',
description: 'Compute, storage and global delivery in a single stack.',
href: '/platform',
},
links: [
{ label: 'Hosting', description: 'Deploy any framework in seconds.', href: '/hosting' },
{ label: 'SQL Database', description: 'Managed Postgres that scales.', href: '/database' },
],
},
{ label: 'Pricing', href: '/pricing' },
]}
/>
);
}Examples
One vivid accent per instance drives the focus rings. Menus without a featured tile collapse to a single column, and the panel morphs to that narrower size.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | NavMenuItem[] | - | Triggers (with links) or plain links (with href). |
accent | string | "#f0883e" | Focus-ring color, set per instance via a CSS variable. |
className | string | - | Forwarded to the <nav> element. |
Each NavMenuItem is either { label, href } (a link) or
{ label, featured?, links } (a dropdown). featured is
{ icon, title, description, href? }; each link is { label, description?, href? }.