FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Navigation Menu

Navigation Menu

pnpm dlx shadcn@latest add …/r/navigation-menu.json

An accessible React navigation menu for Next.js and Tailwind CSS: a top bar with rich dropdown panels that glide between sizes, built on Radix Navigation Menu.

  • Docs
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuIndicator,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
} from "@/components/ui/navigation-menu"

export function SiteNav() {
  return (
    <NavigationMenu>
      <NavigationMenuList>
        <NavigationMenuItem>
          <NavigationMenuTrigger>Components</NavigationMenuTrigger>
          <NavigationMenuContent>
            <ul className="grid w-md grid-cols-2 gap-1">
              <li>
                <NavigationMenuLink href="/components/button">
                  <span className="font-medium">Button</span>
                  <span className="text-muted-foreground">Actions, in calm variants.</span>
                </NavigationMenuLink>
              </li>
              <li>
                <NavigationMenuLink href="/components/dialog">
                  <span className="font-medium">Dialog</span>
                  <span className="text-muted-foreground">A window that holds focus.</span>
                </NavigationMenuLink>
              </li>
            </ul>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem>
          <NavigationMenuLink href="/docs">Docs</NavigationMenuLink>
        </NavigationMenuItem>
        <NavigationMenuIndicator />
      </NavigationMenuList>
    </NavigationMenu>
  )
}
header.tsx
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuIndicator,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
} from "@/components/ui/navigation-menu";
import { BookOpenIcon } from "lucide-react";
import type { ReactNode } from "react";

const guides = [
  {
    href: "/docs/installation",
    title: "Installation",
    description: "Add components with the shadcn CLI, or copy the source.",
  },
  {
    href: "/docs/installation#customize-the-theme",
    title: "Theming",
    description: "Colours, radius and fonts, set with CSS variables.",
  },
  {
    href: "/roadmap",
    title: "Roadmap",
    description: "What's available now, what's next and what's planned.",
  },
];

const components = [
  { href: "/components/button", title: "Button", description: "Actions, in calm variants." },
  { href: "/components/dialog", title: "Dialog", description: "A window that holds focus." },
  { href: "/components/select", title: "Select", description: "One option from a list." },
  { href: "/components/tabs", title: "Tabs", description: "Panels under a sliding line." },
  { href: "/components/toast", title: "Toast", description: "Brief messages that stack." },
  { href: "/components/tooltip", title: "Tooltip", description: "A short label on hover." },
];

/** A panel row: a title over a muted description */
function ListItem({
  href,
  title,
  children,
  className,
}: {
  href: string;
  title: string;
  children: ReactNode;
  className?: string;
}) {
  return (
    <li className={className}>
      <NavigationMenuLink href={href}>
        <span className="font-medium">{title}</span>
        <span className="leading-snug text-muted-foreground">{children}</span>
      </NavigationMenuLink>
    </li>
  );
}

/*
 * A site header's menu: two triggers with panels of different sizes, and a plain link. Moving between the
 * triggers, the panel glides to the next one's size, its content slides in from the side you moved towards,
 * and the caret follows. The box is as tall as the open panel needs.
 */
export default function NavigationMenuHeader() {
  return (
    <div className="flex h-88 w-full items-start justify-center">
      <NavigationMenu aria-label="Documentation">
        <NavigationMenuList>
          <NavigationMenuItem>
            <NavigationMenuTrigger>Getting started</NavigationMenuTrigger>
            <NavigationMenuContent>
              <ul className="grid w-md grid-cols-5 gap-1">
                <li className="col-span-2 row-span-3">
                  <NavigationMenuLink
                    href="/docs"
                    // Its own fill hides the gliding highlight, so it deepens itself on hover
                    className="h-full justify-between gap-4 bg-muted p-4 hover:bg-accent"
                  >
                    <BookOpenIcon className="size-(--size-icon-lg)" />
                    <span className="grid gap-1">
                      <span className="text-base font-medium">Introduction</span>
                      <span className="leading-snug text-muted-foreground">
                        Accessible React components for Next.js and Tailwind CSS, made to copy in.
                      </span>
                    </span>
                  </NavigationMenuLink>
                </li>
                {guides.map((guide) => (
                  <ListItem key={guide.href} href={guide.href} title={guide.title} className="col-span-3">
                    {guide.description}
                  </ListItem>
                ))}
              </ul>
            </NavigationMenuContent>
          </NavigationMenuItem>
          <NavigationMenuItem>
            <NavigationMenuTrigger>Components</NavigationMenuTrigger>
            <NavigationMenuContent>
              <ul className="grid w-lg grid-cols-2 gap-1">
                {components.map((component) => (
                  <ListItem key={component.href} href={component.href} title={component.title}>
                    {component.description}
                  </ListItem>
                ))}
              </ul>
            </NavigationMenuContent>
          </NavigationMenuItem>
          <NavigationMenuItem>
            <NavigationMenuLink href="/docs">Docs</NavigationMenuLink>
          </NavigationMenuItem>
          <NavigationMenuIndicator />
        </NavigationMenuList>
      </NavigationMenu>
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority lucide-react motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

    components/ui/navigation-menu.tsx
    "use client";
    
    import { blur, duration, ease, spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva } from "class-variance-authority";
    import { ChevronDownIcon } from "lucide-react";
    import {
      type AnimationPlaybackControls,
      animate,
      LayoutGroup,
      motion,
      useMotionValue,
      useReducedMotion,
    } from "motion/react";
    import { NavigationMenu as NavigationMenuPrimitive, Slot } from "radix-ui";
    import {
      type ComponentProps,
      createContext,
      type Ref,
      type RefObject,
      useCallback,
      useContext,
      useEffect,
      useId,
      useLayoutEffect,
      useMemo,
      useRef,
      useState,
    } from "react";
    import { createPortal } from "react-dom";
    
    /*
     * Navigation Menu, built on Radix Navigation Menu (roadmap, 2026-10-16: "A top bar with rich dropdown panels").
     * A nav landmark of links and disclosure buttons, each button opening a panel of links. Hover opens after a
     * short delay; click, Enter and Space toggle; ArrowDown opens and moves into the panel; Escape closes and hands
     * focus back to the trigger; Tab runs through the open panel's links.
     * - One highlight glides between the top-level items (layoutId on spring.snappy, scoped to each menu). It
     *   follows the mouse, rests on the open trigger otherwise, and fades out where it was when neither applies.
     * - The panels share one viewport, a floating surface under the list. Moving between triggers, its width and
     *   height glide to the next panel's size (a CSS transition on the size Radix measures, duration-base on
     *   ease-out), while the new panel slides 12px in from the side you moved towards through the blur and the
     *   old one slides out the other way.
     * - The viewport comes through the blur from the list (popover-in, from its top edge) and leaves quickly
     *   (popover-out). An optional caret on its top edge points at the open trigger and slides along with it.
     * - Each trigger's chevron turns over while its panel is open (spring.snappy).
     * - Inside a panel, one highlight glides between the links too (the same layoutId pill, one per panel), on
     *   hover and on keyboard focus, and fades out where it was when you leave. Opening the menu, the links rise
     *   into place through the blur, 0.05s apart; switching panels they slide in with the panel instead.
     * - Nothing animates on first paint. With reduced motion everything appears and moves at once.
     */
    
    /** How far a panel slides in from (and out towards) the side you moved, in px: Tabs' distance */
    const SLIDE = 12;
    
    type Highlight = {
      /** The item the highlight sits in, or null when there's none */
      item: string | null;
      /** False while it fades out in the item it was last in */
      shown: boolean;
      /**
       * Counts appearances from nothing. Each has its own layoutId, so the highlight fades in where it appears
       * rather than flying over from where it last faded out.
       */
      appearance: number;
      /** The first item of an appearance fades the highlight in; the items after it glide it */
      fresh: boolean;
    };
    
    type MenuContextValue = {
      /** The open item, or "" when the menu is closed */
      value: string;
      setValue: (value: string) => void;
      viewport: boolean;
      horizontal: boolean;
      reduce: boolean;
      highlight: Highlight;
      /** The mouse is over an item, or has left it */
      hover: (item: string) => void;
      unhover: (item: string) => void;
      /** The highlight's fade-out has finished: remove it */
      settle: () => void;
      /** The trigger's corner radius as a number, so motion keeps the highlight's corners true as it changes width */
      radius: number | undefined;
      setRadius: (radius: number) => void;
      root: RefObject<HTMLElement | null>;
      /** Each trigger by its item, for the caret */
      triggers: RefObject<Map<string, HTMLButtonElement>>;
      /** Where the caret renders: after the viewport, so it paints over the viewport's top border */
      slot: HTMLElement | null;
      setSlot: (node: HTMLElement | null) => void;
    };
    
    const MenuContext = createContext<MenuContextValue | null>(null);
    /** The item a trigger or panel belongs to */
    const ItemContext = createContext<string | null>(null);
    type PanelContextValue = {
      highlight: Highlight;
      settle: () => void;
      reduce: boolean;
      /** The mouse is over a link, or has left it */
      point: (link: string) => void;
      unpoint: (link: string) => void;
      /** A link has keyboard focus, or has lost it */
      focus: (link: string) => void;
      blur: (link: string) => void;
    };
    
    /** Inside a panel, links are rows that share one gliding highlight; in the list, they look like triggers */
    const PanelContext = createContext<PanelContextValue | null>(null);
    
    /** How far the links rise into place as the menu opens, in px (the library's swap) */
    const RISE = 6;
    /** Links come in 0.05s apart, like items in a list (motion.md) */
    const STAGGER = 0.05;
    
    /**
     * Hands a node to our ref and to the caller's. Stable: a new callback each render would make React detach the
     * ref (null) and attach it again after the children's layout effects, so the caret, measuring in its own
     * layout effect, found no root and stayed at the menu's left edge (2026-10-21).
     */
    function useMergedRef<T>(own: { current: T | null }, theirs?: Ref<T>) {
      return useCallback(
        (node: T | null) => {
          own.current = node;
          if (typeof theirs === "function") theirs(node);
          else if (theirs) (theirs as { current: T | null }).current = node;
        },
        [own, theirs],
      );
    }
    
    /** A node's left offset inside an ancestor, in layout pixels (zoom and transforms don't count) */
    function offsetWithin(node: HTMLElement, ancestor: HTMLElement) {
      let left = 0;
      let current: HTMLElement | null = node;
      while (current && current !== ancestor) {
        left += current.offsetLeft;
        current = current.offsetParent as HTMLElement | null;
      }
      return left;
    }
    
    /**
     * Where the highlight is. `target` is the hovered item, or else the open one. A new target glides the
     * highlight there; none fades it out in place; back on an item while it's still fading, it glides on from there.
     */
    function useHighlight(target: string | null, reduce: boolean) {
      const [highlight, setHighlight] = useState<Highlight>({
        item: target,
        shown: target !== null,
        appearance: 0,
        fresh: false,
      });
      const [last, setLast] = useState(target);
      // Follows the target in the same render, so the highlight never trails a frame behind the open trigger
      if (target !== last) {
        setLast(target);
        setHighlight((current) => {
          // With reduced motion there's no fade to wait for
          if (target === null)
            return reduce ? { ...current, item: null, shown: false } : { ...current, shown: false };
          if (current.item === null)
            return { item: target, shown: true, appearance: current.appearance + 1, fresh: true };
          return { ...current, item: target, shown: true, fresh: false };
        });
      }
      const settle = useCallback(() => {
        setHighlight((current) => (current.shown ? current : { ...current, item: null }));
      }, []);
      return { highlight, settle };
    }
    
    export type NavigationMenuProps = ComponentProps<typeof NavigationMenuPrimitive.Root> & {
      /**
       * Panels open in one shared viewport under the list, which glides between their sizes. Turn it off to open
       * each panel under its own trigger instead.
       */
      viewport?: boolean;
    };
    
    /**
     * A site's navigation: a nav landmark (Radix names it "Main"; pass aria-label for another name). Holds the open
     * item, controlled or not, so the highlight, the panels and the caret know which one is open.
     */
    function NavigationMenu({
      value,
      defaultValue,
      onValueChange,
      viewport = true,
      orientation = "horizontal",
      className,
      children,
      ref,
      ...props
    }: NavigationMenuProps) {
      const reduce = useReducedMotion() ?? false;
      const [internal, setInternal] = useState(defaultValue ?? "");
      const current = value ?? internal;
      const root = useRef<HTMLElement>(null);
      const setRef = useMergedRef(root, ref);
      const triggers = useRef(new Map<string, HTMLButtonElement>());
      const [slot, setSlot] = useState<HTMLElement | null>(null);
      const [radius, setRadius] = useState<number>();
      const [hovered, setHovered] = useState<string | null>(null);
      const { highlight, settle } = useHighlight(hovered ?? (current || null), reduce);
    
      const setValue = useCallback(
        (next: string) => {
          setInternal(next);
          onValueChange?.(next);
        },
        [onValueChange],
      );
      const hover = useCallback((item: string) => setHovered(item), []);
      const unhover = useCallback(
        (item: string) => setHovered((currentItem) => (currentItem === item ? null : currentItem)),
        [],
      );
    
      const menu = useMemo<MenuContextValue>(
        () => ({
          value: current,
          setValue,
          viewport,
          horizontal: orientation === "horizontal",
          reduce,
          highlight,
          hover,
          unhover,
          settle,
          radius,
          setRadius,
          root,
          triggers,
          slot,
          setSlot,
        }),
        [current, setValue, viewport, orientation, reduce, highlight, hover, unhover, settle, radius, slot],
      );
    
      return (
        <MenuContext.Provider value={menu}>
          <NavigationMenuPrimitive.Root
            ref={setRef}
            data-slot="navigation-menu"
            data-viewport={viewport}
            value={current}
            onValueChange={setValue}
            orientation={orientation}
            className={cn(
              // h-max: never stretched by a flex row, since the viewport hangs from its bottom edge
              "group/navigation-menu relative flex h-max max-w-max flex-1 items-center justify-center",
              className,
            )}
            {...props}
          >
            {children}
            {viewport ? <NavigationMenuViewport /> : null}
          </NavigationMenuPrimitive.Root>
        </MenuContext.Provider>
      );
    }
    
    /**
     * The row of items. Its own layer, so the highlight passes behind every label as it glides. Without the
     * viewport the panels open inside that layer, so it rises to the popover level while one is there.
     */
    function NavigationMenuList({
      className,
      children,
      ...props
    }: ComponentProps<typeof NavigationMenuPrimitive.List>) {
      const groupId = useId();
      return (
        <NavigationMenuPrimitive.List
          data-slot="navigation-menu-list"
          className={cn(
            "relative isolate flex flex-1 list-none items-center justify-center gap-1 has-data-[slot=navigation-menu-content]:z-(--z-popover)",
            className,
          )}
          {...props}
        >
          <LayoutGroup id={groupId}>{children}</LayoutGroup>
        </NavigationMenuPrimitive.List>
      );
    }
    
    /**
     * The highlight, inside the item that has it. Items aren't stacking contexts: it joins the list's layer (the
     * list is `isolate`), so it passes behind every label whichever way it glides. A fill, so it only fades.
     */
    function ItemHighlight({ item }: { item: string }) {
      const menu = useContext(MenuContext);
      const node = useRef<HTMLSpanElement>(null);
      const here = menu?.highlight.item === item;
      const radius = menu?.radius;
      const setRadius = menu?.setRadius;
    
      // Read once from the class, then motion keeps the corners true while the highlight changes width
      useLayoutEffect(() => {
        if (here && radius === undefined && node.current && setRadius)
          setRadius(Number.parseFloat(getComputedStyle(node.current).borderTopLeftRadius) || 0);
      }, [here, radius, setRadius]);
    
      if (!menu || !here) return null;
      const { highlight, reduce, settle } = menu;
      return (
        <motion.span
          ref={node}
          layoutId={`navigation-menu-highlight-${highlight.appearance}`}
          aria-hidden
          data-slot="navigation-menu-highlight"
          className="pointer-events-none absolute inset-0 -z-10 rounded-md bg-accent"
          style={radius ? { borderRadius: radius } : undefined}
          initial={highlight.fresh ? { opacity: 0 } : false}
          animate={{ opacity: highlight.shown ? 1 : 0 }}
          transition={
            reduce ? { duration: 0 } : { ...spring.snappy, opacity: { duration: duration.fast, ease: ease.out } }
          }
          onAnimationComplete={() => {
            if (!highlight.shown) settle();
          }}
        />
      );
    }
    
    /** One item of the list: a trigger with its panel, or a link */
    function NavigationMenuItem({
      className,
      value,
      children,
      onPointerEnter,
      onPointerLeave,
      ...props
    }: ComponentProps<typeof NavigationMenuPrimitive.Item>) {
      const menu = useContext(MenuContext);
      const auto = useId();
      // Radix makes its own when there's none; ours is the same idea, so we know which item is open
      const item = value || auto;
    
      return (
        <ItemContext.Provider value={item}>
          <NavigationMenuPrimitive.Item
            data-slot="navigation-menu-item"
            value={item}
            className={cn("relative", className)}
            onPointerEnter={(event) => {
              onPointerEnter?.(event);
              // Mouse only: on touch the highlight just marks the open trigger. Never on a disabled trigger.
              if (event.pointerType !== "mouse" || event.currentTarget.querySelector(":scope > [data-disabled]"))
                return;
              menu?.hover(item);
            }}
            onPointerLeave={(event) => {
              onPointerLeave?.(event);
              if (event.pointerType === "mouse") menu?.unhover(item);
            }}
            {...props}
          >
            <ItemHighlight item={item} />
            {children}
          </NavigationMenuPrimitive.Item>
        </ItemContext.Provider>
      );
    }
    
    /**
     * The look of a top-level item: a trigger, or a link in the list. Flat: the hover and open fill is the
     * gliding highlight, not a background of its own. Muted labels turn foreground on hover, open or current.
     */
    const navigationMenuTriggerStyle = cva([
      "relative inline-flex h-(--size-control-md) w-max cursor-pointer items-center justify-center gap-1 rounded-md px-3 text-sm font-medium whitespace-nowrap",
      "text-muted-foreground transition-colors duration-(--duration-fast) ease-out hover:text-foreground data-active:text-foreground data-[state=open]:text-foreground",
      "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
      "disabled:pointer-events-none disabled:opacity-(--opacity-disabled)",
      "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md)",
    ]);
    
    /** The button that opens an item's panel. Its chevron turns over while the panel is open. */
    function NavigationMenuTrigger({
      className,
      children,
      onKeyDown,
      ref,
      ...props
    }: ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
      const menu = useContext(MenuContext);
      const item = useContext(ItemContext);
      const open = menu !== null && item !== null && menu.value === item;
      const node = useRef<HTMLButtonElement>(null);
      const setRef = useMergedRef(node, ref);
      const entering = useRef(false);
      const triggers = menu?.triggers;
    
      // Known to the caret, which slides to the open trigger
      useLayoutEffect(() => {
        const trigger = node.current;
        const map = triggers?.current;
        if (!trigger || !map || item === null) return;
        map.set(item, trigger);
        return () => {
          if (map.get(item) === trigger) map.delete(item);
        };
      }, [triggers, item]);
    
      // Opened with ArrowDown: once the panel is in, move into it the way Radix does for ArrowDown on an open
      // trigger (its own handler also gives the links back their tab order)
      useEffect(() => {
        if (!open || !entering.current) return;
        entering.current = false;
        const frame = requestAnimationFrame(() =>
          node.current?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })),
        );
        return () => cancelAnimationFrame(frame);
      }, [open]);
    
      return (
        <NavigationMenuPrimitive.Trigger
          ref={setRef}
          data-slot="navigation-menu-trigger"
          className={cn(navigationMenuTriggerStyle(), className)}
          onKeyDown={(event) => {
            onKeyDown?.(event);
            if (event.defaultPrevented || !menu || item === null) return;
            // On a closed trigger, Radix would move to the next one; opening is what ArrowDown means here
            if (event.key === "ArrowDown" && menu.horizontal && !open) {
              event.preventDefault();
              entering.current = true;
              menu.setValue(item);
            }
          }}
          {...props}
        >
          {children}
          <motion.span
            aria-hidden
            className="inline-flex"
            initial={false}
            animate={{ rotate: open ? 180 : 0 }}
            transition={menu?.reduce ? { duration: 0 } : spring.snappy}
          >
            <ChevronDownIcon className="size-(--size-icon-sm)" />
          </motion.span>
        </NavigationMenuPrimitive.Trigger>
      );
    }
    
    type PanelProps = ComponentProps<"div"> & {
      viewport: boolean;
      leaving: boolean;
      onLeft: () => void;
      /** Set by Radix: the side the panel enters from or leaves towards (from-start, to-end...) */
      "data-motion"?: string | null;
    };
    
    /**
     * The panel's own element: Radix renders the content through it (asChild), handing over its attributes. In the
     * viewport it plays the slide; without one it's a floating surface of its own, under its trigger.
     */
    function NavigationMenuPanel({ viewport, leaving, onLeft, className, children, ref, ...props }: PanelProps) {
      const menu = useContext(MenuContext);
      const reduce = menu?.reduce ?? false;
      const node = useRef<HTMLDivElement>(null);
      const setRef = useMergedRef(node, ref);
      const fresh = useRef(true);
      const side = props["data-motion"];
    
      // One highlight for the panel's links: the one under the mouse, else the one with keyboard focus
      const groupId = useId();
      const [pointed, setPointed] = useState<string | null>(null);
      const [focused, setFocused] = useState<string | null>(null);
      const { highlight, settle } = useHighlight(pointed ?? focused, reduce);
      const panel = useMemo<PanelContextValue>(
        () => ({
          highlight,
          settle,
          reduce,
          point: (link) => setPointed(link),
          unpoint: (link) => setPointed((current) => (current === link ? null : current)),
          focus: (link) => setFocused(link),
          blur: (link) => setFocused((current) => (current === link ? null : current)),
        }),
        [highlight, settle, reduce],
      );
    
      // Before the first paint, so a panel never flashes in place before it slides
      // biome-ignore lint/correctness/useExhaustiveDependencies: only arriving or leaving starts a slide
      useLayoutEffect(() => {
        const panel = node.current;
        const first = fresh.current;
        fresh.current = false;
        if (!panel || !viewport || reduce) return;
        // Radix's start and end are the left and right of the screen (it flips them for right-to-left pages)
        const towards = side?.endsWith("start") ? -1 : 1;
        let live = true;
        let running: AnimationPlaybackControls | undefined;
    
        if (leaving) {
          // Slides out the other way through the blur, quicker than the new panel comes in, so the two barely overlap
          const exit = animate(
            panel,
            { opacity: 0, x: towards * SLIDE, filter: blur.hidden },
            { duration: duration.fast, ease: ease.out },
          );
          exit.then(() => {
            if (live) onLeft();
          });
          running = exit;
        } else if (first && !side) {
          // Opening the menu (not switching panels): the links rise into place through the blur, in turn
          const links = Array.from(panel.querySelectorAll<HTMLElement>("[data-slot=navigation-menu-link]"));
          const rises = links.map((link, index) =>
            animate(
              link,
              { opacity: [0, 1], y: [RISE, 0], filter: [blur.hidden, blur.shown] },
              { duration: duration.slow, ease: ease.out, delay: index * STAGGER },
            ),
          );
          const clear = () => {
            for (const link of links)
              for (const property of ["opacity", "transform", "filter"]) link.style.removeProperty(property);
          };
          Promise.all(rises).then(() => {
            if (live) clear();
          });
          return () => {
            live = false;
            for (const rise of rises) rise.stop();
            clear();
          };
        } else if (side === "from-start" || side === "from-end") {
          // A panel that was sliding away and is wanted back turns round from where it is
          const entrance = animate(
            panel,
            first
              ? { opacity: [0, 1], x: [towards * SLIDE, 0], filter: [blur.hidden, blur.shown] }
              : { opacity: 1, x: 0, filter: blur.shown },
            { duration: duration.base, ease: ease.out },
          );
          // A filter left behind would make a stacking context and a containing block round the links
          entrance.then(() => {
            if (live) panel.style.removeProperty("filter");
          });
          running = entrance;
        }
        return () => {
          live = false;
          running?.stop();
        };
      }, [leaving, side]);
    
      return (
        <div
          {...props}
          ref={setRef}
          data-slot="navigation-menu-content"
          // On its way out: out of reach for the pointer, the keyboard and screen readers
          inert={leaving || undefined}
          className={cn(
            viewport
              ? // Centred, so a panel of another size stays put while the viewport glides round it. w-max: the panel
                // is as wide as its content, never squeezed by the viewport it's about to resize. Its own layer, so
                // the links' highlight passes behind every row whichever way it glides.
                "absolute top-0 left-1/2 isolate w-max -translate-x-1/2 p-2"
              : "absolute start-0 top-full isolate z-(--z-popover) mt-2 w-max origin-top-left rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-md data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out rtl:origin-top-right",
            leaving && "pointer-events-none",
            className,
          )}
        >
          <PanelContext.Provider value={panel}>
            <LayoutGroup id={groupId}>{children}</LayoutGroup>
          </PanelContext.Provider>
        </div>
      );
    }
    
    export type NavigationMenuContentProps = Omit<
      ComponentProps<typeof NavigationMenuPrimitive.Content>,
      "forceMount" | "asChild"
    >;
    
    /**
     * A trigger's panel. Give what's inside a width (`w-md`, a grid), so the viewport knows the size to glide to.
     */
    function NavigationMenuContent({ className, children, ref, ...props }: NavigationMenuContentProps) {
      const menu = useContext(MenuContext);
      const item = useContext(ItemContext);
      const viewport = menu?.viewport ?? true;
      const active = menu !== null && item !== null && menu.value === item;
      // Moving to another panel in the viewport, this one stays mounted while it slides away. When the menu closes
      // it goes with the viewport instead.
      const switching = viewport && !(menu?.reduce ?? false) && (menu?.value ?? "") !== "";
      const [was, setWas] = useState(active);
      const [leaving, setLeaving] = useState(false);
      if (was !== active) {
        setWas(active);
        setLeaving(!active && switching);
      } else if (leaving && !switching) setLeaving(false);
      const left = useCallback(() => setLeaving(false), []);
    
      return (
        <NavigationMenuPrimitive.Content
          // In the viewport the shown panel and the one sliding away stay mounted: they play their own motion
          forceMount={viewport && (active || leaving) ? true : undefined}
          {...props}
          asChild
        >
          <NavigationMenuPanel
            ref={ref}
            viewport={viewport}
            leaving={leaving}
            onLeft={left}
            className={className}
          >
            {children}
          </NavigationMenuPanel>
        </NavigationMenuPrimitive.Content>
      );
    }
    
    /** A panel row: a title over a muted description. The hover fill is the panel's gliding highlight. */
    const panelLinkClasses = [
      "relative flex flex-col gap-1 rounded-md p-3 text-sm outline-ring transition-colors duration-(--duration-fast) ease-out",
      "hover:text-accent-foreground data-active:bg-accent/50 data-active:text-accent-foreground",
      "focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
      "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md) [&_svg:not([class*='text-'])]:text-muted-foreground",
    ];
    
    /**
     * A link. In the list it looks like a trigger; inside a panel it's a row. Selecting it closes the menu. Use
     * asChild with your router's link, and `active` for the current page.
     */
    function NavigationMenuLink({
      className,
      children,
      onPointerEnter,
      onPointerLeave,
      onFocus,
      onBlur,
      ...props
    }: ComponentProps<typeof NavigationMenuPrimitive.Link>) {
      const panel = useContext(PanelContext);
      const link = useId();
      if (!panel)
        return (
          <NavigationMenuPrimitive.Link
            data-slot="navigation-menu-link"
            className={cn(navigationMenuTriggerStyle(), className)}
            onPointerEnter={onPointerEnter}
            onPointerLeave={onPointerLeave}
            onFocus={onFocus}
            onBlur={onBlur}
            {...props}
          >
            {children}
          </NavigationMenuPrimitive.Link>
        );
      return (
        <NavigationMenuPrimitive.Link
          data-slot="navigation-menu-link"
          className={cn(panelLinkClasses, className)}
          onPointerEnter={(event) => {
            onPointerEnter?.(event);
            // Mouse only: a tap goes straight to the page
            if (event.pointerType === "mouse") panel.point(link);
          }}
          onPointerLeave={(event) => {
            onPointerLeave?.(event);
            if (event.pointerType === "mouse") panel.unpoint(link);
          }}
          onFocus={(event) => {
            onFocus?.(event);
            panel.focus(link);
          }}
          onBlur={(event) => {
            onBlur?.(event);
            panel.blur(link);
          }}
          {...props}
        >
          <PanelHighlight link={link} />
          {/* With asChild the child (your router's link) becomes the row, and the highlight goes inside it */}
          <Slot.Slottable>{children}</Slot.Slottable>
        </NavigationMenuPrimitive.Link>
      );
    }
    
    /**
     * The panel's highlight, inside the link that has it. Links aren't stacking contexts: it joins the panel's
     * layer (the panel is `isolate`), so it passes behind every row's text. A fill, so it only fades.
     */
    function PanelHighlight({ link }: { link: string }) {
      const panel = useContext(PanelContext);
      if (!panel || panel.highlight.item !== link) return null;
      const { highlight, reduce, settle } = panel;
      return (
        <motion.span
          layoutId={`navigation-menu-panel-highlight-${highlight.appearance}`}
          aria-hidden
          data-slot="navigation-menu-panel-highlight"
          className="pointer-events-none absolute inset-0 -z-10 rounded-md bg-accent"
          initial={highlight.fresh ? { opacity: 0 } : false}
          animate={{ opacity: highlight.shown ? 1 : 0 }}
          transition={
            reduce ? { duration: 0 } : { ...spring.snappy, opacity: { duration: duration.fast, ease: ease.out } }
          }
          onAnimationComplete={() => {
            if (!highlight.shown) settle();
          }}
        />
      );
    }
    
    /**
     * The floating surface the panels open in, under the list. NavigationMenu renders it unless `viewport` is
     * false. Its width and height are Radix's measure of the open panel; changing panels, they glide there.
     */
    function NavigationMenuViewport({
      className,
      ...props
    }: ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
      const menu = useContext(MenuContext);
      return (
        // Spans the menu and centres the viewport under it. Only the viewport takes the pointer.
        <div className="pointer-events-none absolute inset-x-0 top-full z-(--z-popover) flex justify-center">
          <NavigationMenuPrimitive.Viewport
            data-slot="navigation-menu-viewport"
            className={cn(
              // box-content: the measured size is the panel's, and the border sits outside it
              "pointer-events-auto relative mt-2 box-content h-(--radix-navigation-menu-viewport-height) w-(--radix-navigation-menu-viewport-width) shrink-0 origin-top overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md",
              // The size glides between panels (no transition on the first measure: there's nothing to glide from).
              // Height and width move here, a documented exception: decisions.md. Instant with reduced motion.
              "[transition-property:width,height] duration-(--duration-base) ease-out",
              "data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out",
              className,
            )}
            {...props}
          />
          {/* After the viewport, so the caret paints over its top border */}
          <span ref={menu?.setSlot} className="pointer-events-none absolute inset-x-0 top-0 h-2" />
        </div>
      );
    }
    
    /** The caret itself, on the viewport's top edge */
    function Caret({ menu, className }: { menu: MenuContextValue; className?: string }) {
      const { value, reduce, root, triggers, slot } = menu;
      const open = value !== "";
      const x = useMotionValue(0);
      // It has a place in this opening: from here on it slides; the first place of an opening it jumps to
      const placed = useRef(false);
    
      useLayoutEffect(() => {
        if (!open) {
          placed.current = false;
          return;
        }
        let last: number | undefined;
        const place = (glide: boolean) => {
          const trigger = triggers.current.get(value);
          const box = root.current;
          if (!trigger || !box || !slot) return;
          // Offsets, not on-screen boxes, so a zoomed or scaled page doesn't double the trip
          const centre = offsetWithin(trigger, box) + trigger.offsetWidth / 2 - offsetWithin(slot, box);
          if (centre === last) return;
          last = centre;
          // jump() resets the speed, so a later slide never starts with the jump counted as velocity
          if (glide && placed.current && !reduce) animate(x, centre, spring.snappy);
          else x.jump(centre);
          placed.current = true;
        };
        place(true);
        // Fonts loading or the page resizing move the triggers: follow without sliding
        const observer = new ResizeObserver(() => place(false));
        if (root.current) observer.observe(root.current);
        return () => observer.disconnect();
      }, [open, value, reduce, root, triggers, slot, x]);
    
      useEffect(() => () => x.stop(), [x]);
    
      return (
        <motion.span
          aria-hidden
          data-slot="navigation-menu-indicator"
          data-state={open ? "visible" : "hidden"}
          // Its base covers the viewport's top border, so the caret and the surface read as one shape
          className={cn("absolute bottom-0 left-0 flex h-1.5 w-3 -translate-x-1/2 translate-y-px", className)}
          style={{ x }}
          initial={false}
          // With the viewport's own timing: in on popover-in's, out on popover-out's. A fill, so it only fades.
          animate={{ opacity: open ? 1 : 0 }}
          transition={
            reduce
              ? { duration: 0 }
              : open
                ? { duration: duration.fast, ease: ease.out }
                : { duration: duration.instant, ease: ease.in }
          }
        >
          <svg viewBox="0 0 12 6" fill="none" aria-hidden className="size-full overflow-visible">
            <path d="M0 6 6 0l6 6" className="fill-popover stroke-border" />
          </svg>
        </motion.span>
      );
    }
    
    /**
     * A caret on the viewport's top edge, pointing at the open trigger; it slides along when another opens. Place
     * it in NavigationMenuList, as on shadcn. It needs the viewport, so it renders nothing with `viewport={false}`.
     */
    function NavigationMenuIndicator({ className }: { className?: string }) {
      const menu = useContext(MenuContext);
      if (!menu?.slot) return null;
      return createPortal(<Caret menu={menu} className={className} />, menu.slot);
    }
    
    export {
      NavigationMenu,
      NavigationMenuContent,
      NavigationMenuIndicator,
      NavigationMenuItem,
      NavigationMenuLink,
      NavigationMenuList,
      NavigationMenuTrigger,
      NavigationMenuViewport,
      navigationMenuTriggerStyle,
    };
  5. 5

    Update the import paths to match your project.

Examples

Panels under their triggers

With viewport={false}, each trigger opens its own small surface right under it, like a row of dropdowns, instead of one shared panel.

  • Docs
without-viewport.tsx
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
} from "@/components/ui/navigation-menu";

const menus = [
  {
    label: "Guides",
    links: [
      { href: "/docs", title: "Introduction" },
      { href: "/docs/installation", title: "Installation" },
      { href: "/roadmap", title: "Roadmap" },
    ],
  },
  {
    label: "Components",
    links: [
      { href: "/components/button", title: "Button" },
      { href: "/components/dialog", title: "Dialog" },
      { href: "/components/select", title: "Select" },
      { href: "/components", title: "All components" },
    ],
  },
];

/*
 * viewport={false}: each trigger opens its own small surface right under it, like a row of dropdowns. Plain
 * rows of links, spaced like Select's options.
 */
export default function NavigationMenuWithoutViewport() {
  return (
    <div className="flex h-64 w-full items-start justify-center">
      <NavigationMenu viewport={false} aria-label="Documentation">
        <NavigationMenuList>
          {menus.map((menu) => (
            <NavigationMenuItem key={menu.label}>
              <NavigationMenuTrigger>{menu.label}</NavigationMenuTrigger>
              <NavigationMenuContent className="p-1">
                <ul className="grid w-48">
                  {menu.links.map((link) => (
                    <li key={link.href}>
                      <NavigationMenuLink href={link.href} className="px-2 py-1.5">
                        {link.title}
                      </NavigationMenuLink>
                    </li>
                  ))}
                </ul>
              </NavigationMenuContent>
            </NavigationMenuItem>
          ))}
          <NavigationMenuItem>
            <NavigationMenuLink href="/docs">Docs</NavigationMenuLink>
          </NavigationMenuItem>
        </NavigationMenuList>
      </NavigationMenu>
    </div>
  );
}

API reference

NavigationMenu

The root, a nav landmark built on Radix Navigation Menu. Also accepts every Radix Navigation Menu Root prop. Hover opens a panel after a short delay; click, Enter and Space toggle it; ArrowDown opens it and moves in; Escape closes it.

PropTypeDefaultDescription
value?string–The open item, when you control it. An empty string is closed.
defaultValue?string""The item open at first.
onValueChange?(value: string) => void–Called with the item that opens, or an empty string when the menu closes.
viewport?booleantrueOpens the panels in one shared surface under the list, which glides between their sizes. Set it to false to open each panel under its own trigger.
delayDuration?number200How long the pointer rests on a trigger before its panel opens, in milliseconds.
skipDelayDuration?number300How soon after leaving one trigger another opens without the delay, in milliseconds.
aria-label?string"Main"Names the landmark. Give each navigation menu on a page its own name.

NavigationMenuList

The row of items. Place NavigationMenuIndicator inside it, after the items.

PropTypeDefaultDescription
children?ReactNode–NavigationMenuItem elements.

NavigationMenuItem

One item: a trigger with its content, or a link. One highlight glides to the item under the mouse, and otherwise rests on the open one.

PropTypeDefaultDescription
value?string–Names the item for value and onValueChange. One is made up when you leave it out.

NavigationMenuTrigger

The button that opens its item's panel, with a chevron that turns over while it's open. Also accepts every Button element prop.

PropTypeDefaultDescription
disabled?booleanfalseCan't be opened or focused.

NavigationMenuContent

The item's panel. In the viewport it slides 12px in from the side you moved towards through a soft blur, while the previous panel slides out the other way. Give what's inside a width (for example w-md on a grid), so the viewport knows the size to glide to.

PropTypeDefaultDescription
children?ReactNode–The panel's links and layout.
onEscapeKeyDown?(event: KeyboardEvent) => void–Called when Escape closes the menu. Call event.preventDefault() to keep it open.

NavigationMenuLink

A link. In the list it looks like a trigger; inside a panel it's a row that fills on hover. Selecting it closes the menu.

PropTypeDefaultDescription
href?string–Where it goes.
active?booleanfalseMarks the current page (aria-current="page").
asChild?booleanfalseRenders your own link instead, for example Next.js's Link.
onSelect?(event: Event) => void–Called when the link is selected. Call event.preventDefault() to keep the menu open.

NavigationMenuIndicator

A caret on the panel's top edge that points at the open trigger and slides along when another opens. It needs the viewport.

PropTypeDefaultDescription
className?string–Classes for the caret.

NavigationMenuViewport

The shared surface the panels open in. NavigationMenu renders it for you unless viewport is false. It opens through a soft blur, glides between panel sizes and closes quickly; with reduced motion it all happens at once.

PropTypeDefaultDescription
className?string–Classes for the surface.

navigationMenuTriggerStyle

The trigger's classes, for a link or button of your own in the list.

PropTypeDefaultDescription
()?() => string–Returns the class names. Merge your own with cn.

Related components

  • Dropdown Menu

    An accessible React dropdown menu for Next.js with a highlight that glides between items, shortcuts, checkbox and radio items and sub-menus, built on Radix.

  • Your name and email, as others see them.
    Tabs

    Accessible React tabs for Next.js, with an underline or pill that slides to the active tab and panels that glide in, built on Radix Tabs.

    1. Home
    2. Components
    3. Breadcrumb
    Breadcrumb

    An accessible React breadcrumb for Next.js and Tailwind CSS: a trail of links whose levels animate as you move, with custom separators and a collapsed middle.