liten

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.

Terminal
bun add motion
components/ui/navigation-menu.tsx
'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

Example.tsx
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

PropTypeDefaultDescription
itemsNavMenuItem[]-Triggers (with links) or plain links (with href).
accentstring"#f0883e"Focus-ring color, set per instance via a CSS variable.
classNamestring-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? }.

On this page0%