trove/cn

Navigation Menu

A shared-viewport mega menu with preview panels.

Mega menu

A featured getting-started panel and a compact component grid.

mega-menu

Resizing viewport

Two very differently sized panels sharing one viewport.

resize

Install

Install the packages this component imports.

bun add @base-ui/react motion lucide-react
Terminal

Also requires lib/utils.ts, hooks/use-proximity-hover.ts (see Installation).

Source

Copy and paste the following code into your project.

"use client";

import {
  Children,
  cloneElement,
  createContext,
  isValidElement,
  useContext,
  useEffect,
  useMemo,
  useRef,
  type ReactElement,
  type ReactNode,
} from "react";
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu";
import { ChevronDownIcon } from "lucide-react";

import { cn } from "@/lib/utils";
import { ProximityHoverPill } from "@/components/ui/proximity-hover-pill";
import { useProximityHover, type ItemRect } from "@/hooks/use-proximity-hover";

const viewportTransition =
  "transition-[top,left,right,bottom,width,height] delay-50 duration-200 ease-out data-ending-style:delay-0 data-ending-style:duration-150 data-instant:transition-none";
const popupTransition =
  "transition-[opacity,transform,width,height] delay-50 duration-200 ease-out data-ending-style:delay-0 data-ending-style:duration-150";
const contentTransition =
  "transition-[opacity,transform] duration-200 ease-out data-ending-style:duration-150";

interface NavigationMenuProximityContextValue {
  registerItem: (index: number, element: HTMLElement | null) => void;
}

const NavigationMenuProximityContext = createContext<NavigationMenuProximityContextValue | null>(
  null,
);

type NavigationMenuIndexProp = { _index?: number };

function indexNavigationMenuItems(children: ReactNode): ReactNode {
  let index = -1;
  return Children.map(children, (child) => {
    if (!isValidElement(child) || child.type !== NavigationMenuItem) return child;
    index += 1;
    return cloneElement(child as ReactElement<NavigationMenuIndexProp>, { _index: index });
  });
}

function NavigationMenu({
  align = "start",
  className,
  children,
  ...props
}: NavigationMenuPrimitive.Root.Props & Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
  return (
    <NavigationMenuPrimitive.Root
      data-slot="navigation-menu"
      className={cn(
        "group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
        className,
      )}
      {...props}
    >
      {children}
      <NavigationMenuPositioner align={align} />
    </NavigationMenuPrimitive.Root>
  );
}

function NavigationMenuList({ className, children, ...props }: NavigationMenuPrimitive.List.Props) {
  const containerRef = useRef<HTMLUListElement>(null);
  const { activeIndex, itemRects, handlers, registerItem, measureItems } = useProximityHover(
    containerRef,
    { axis: "x" },
  );

  useEffect(() => {
    measureItems();
  }, [measureItems, children]);

  const activeRect: ItemRect | null = activeIndex !== null ? itemRects[activeIndex] : null;
  const proximityContextValue = useMemo(() => ({ registerItem }), [registerItem]);

  return (
    <NavigationMenuPrimitive.List
      ref={containerRef}
      data-slot="navigation-menu-list"
      className={cn(
        "group relative flex flex-1 list-none items-center justify-center gap-0.5",
        className,
      )}
      onMouseMove={handlers.onMouseMove}
      onMouseEnter={handlers.onMouseEnter}
      onMouseLeave={handlers.onMouseLeave}
      {...props}
    >
      <ProximityHoverPill activeRect={activeRect} sessionKey={0} />
      <NavigationMenuProximityContext.Provider value={proximityContextValue}>
        {indexNavigationMenuItems(children)}
      </NavigationMenuProximityContext.Provider>
    </NavigationMenuPrimitive.List>
  );
}

function NavigationMenuItem({
  className,
  _index,
  ...props
}: NavigationMenuPrimitive.Item.Props & NavigationMenuIndexProp) {
  const ref = useRef<HTMLLIElement>(null);
  const ctx = useContext(NavigationMenuProximityContext);

  useEffect(() => {
    if (_index === undefined || !ctx) return;
    ctx.registerItem(_index, ref.current);
    return () => ctx.registerItem(_index, null);
    // oxlint-disable-next-line react-hooks/exhaustive-deps
  }, [_index, ctx]);

  return (
    <NavigationMenuPrimitive.Item
      ref={ref}
      data-slot="navigation-menu-item"
      className={cn("relative z-10", className)}
      {...props}
    />
  );
}

function NavigationMenuTrigger({
  className,
  children,
  ...props
}: NavigationMenuPrimitive.Trigger.Props) {
  return (
    <NavigationMenuPrimitive.Trigger
      data-slot="navigation-menu-trigger"
      className={cn(
        "group inline-flex h-8 w-max cursor-default items-center justify-center gap-1 rounded-lg px-2.5 text-control text-muted-foreground outline-none transition-colors select-none hover:bg-muted hover:text-foreground data-popup-open:bg-muted data-popup-open:text-foreground",
        className,
      )}
      {...props}
    >
      {children}
      <ChevronDownIcon
        aria-hidden="true"
        className="size-3 transition-transform duration-fast group-data-popup-open:rotate-180"
      />
    </NavigationMenuPrimitive.Trigger>
  );
}

function NavigationMenuContent({ className, ...props }: NavigationMenuPrimitive.Content.Props) {
  return (
    <NavigationMenuPrimitive.Content
      data-slot="navigation-menu-content"
      className={cn(
        "h-full w-auto p-1 outline-none",
        contentTransition,
        "data-starting-style:opacity-0 data-ending-style:opacity-0",
        "data-starting-style:data-[activation-direction=left]:-translate-x-1 data-starting-style:data-[activation-direction=right]:translate-x-1",
        "data-starting-style:data-[activation-direction=up]:-translate-y-1 data-starting-style:data-[activation-direction=down]:translate-y-1",
        "group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-popover",
        "**:data-[slot=navigation-menu-link]:outline-none",
        className,
      )}
      {...props}
    />
  );
}

function NavigationMenuPositioner({
  className,
  side = "bottom",
  sideOffset = 8,
  align = "start",
  alignOffset = 0,
  ...props
}: NavigationMenuPrimitive.Positioner.Props) {
  return (
    <NavigationMenuPrimitive.Portal>
      <NavigationMenuPrimitive.Positioner
        side={side}
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
        className={cn(
          "isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) before:absolute before:inset-x-0 before:content-[''] data-[side=bottom]:before:-top-2 data-[side=bottom]:before:h-2",
          viewportTransition,
          className,
        )}
        {...props}
      >
        <NavigationMenuPrimitive.Popup
          className={cn(
            "relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-popover outline-none",
            popupTransition,
            "data-starting-style:opacity-0 data-ending-style:opacity-0",
          )}
        >
          <NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
        </NavigationMenuPrimitive.Popup>
      </NavigationMenuPrimitive.Positioner>
    </NavigationMenuPrimitive.Portal>
  );
}

function NavigationMenuLink({ className, ...props }: NavigationMenuPrimitive.Link.Props) {
  return (
    <NavigationMenuPrimitive.Link
      data-slot="navigation-menu-link"
      className={cn(
        "flex items-center gap-2 rounded-md p-2 text-control text-muted-foreground outline-none transition-colors select-none hover:bg-hover hover:text-foreground data-active:bg-active data-active:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:text-muted-foreground",
        className,
      )}
      {...props}
    />
  );
}

export {
  NavigationMenu,
  NavigationMenuList,
  NavigationMenuItem,
  NavigationMenuTrigger,
  NavigationMenuContent,
  NavigationMenuLink,
};
navigation-menu.tsx

API Reference

NavigationMenu
align"start"

"start" | "center" | "end"

Alignment of the shared viewport relative to the active trigger.

API Reference

NavigationMenuItemNavigationMenuTriggerNavigationMenuContent
children

ReactNode

NavigationMenuTrigger's label; NavigationMenuContent's panel content.

API Reference

NavigationMenuLink
href

string

Renders as an anchor by default; pass `render` to use a router Link.

activefalse

boolean

Marks the link as the current page.