Siteplane UI

Navigation Menu

Open site sections from a row of links with dropdown panels.

Markdown
"use client";

import { useRef } from "react";
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuPopup,
  NavigationMenuTrigger,
} from "@/siteplane/components/navigation-menu";

export default function Example() {
  const anchor = useRef<HTMLUListElement>(null);

  return (
    <NavigationMenu aria-label="Main navigation">
      <NavigationMenuList ref={anchor}>
        <NavigationMenuItem value="product">
          <NavigationMenuTrigger>Product</NavigationMenuTrigger>
          <NavigationMenuContent>
            <div className="grid w-80 max-w-full gap-1 p-4">
              <NavigationMenuLink
                description="See what is happening across your workspace."
                href="#overview"
              >
                Overview
              </NavigationMenuLink>
              <NavigationMenuLink
                description="Track work from planning to launch."
                href="#projects"
              >
                Projects
              </NavigationMenuLink>
              <NavigationMenuLink
                description="Understand how your work performs."
                href="#analytics"
              >
                Analytics
              </NavigationMenuLink>
            </div>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem value="resources">
          <NavigationMenuTrigger>Resources</NavigationMenuTrigger>
          <NavigationMenuContent>
            <div className="grid w-96 max-w-full gap-1 p-4">
              <NavigationMenuLink
                description="Guides and references for every feature."
                href="#documentation"
              >
                Documentation
              </NavigationMenuLink>
              <NavigationMenuLink
                description="The latest improvements and releases."
                href="#changelog"
              >
                Changelog
              </NavigationMenuLink>
            </div>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem>
          <NavigationMenuLink href="#pricing">Pricing</NavigationMenuLink>
        </NavigationMenuItem>
      </NavigationMenuList>
      <NavigationMenuPopup anchor={anchor} />
    </NavigationMenu>
  );
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/navigation-menu

Complete the one-time setup first. Registry JSON lists source files and dependencies.

Usage

import { useRef } from "react";
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuPopup,
  NavigationMenuTrigger,
} from "@/siteplane/components/navigation-menu";

function SiteNavigation() {
  const anchor = useRef<HTMLUListElement>(null);

  return (
    <NavigationMenu aria-label="Main navigation">
      <NavigationMenuList ref={anchor}>
        <NavigationMenuItem value="product">
          <NavigationMenuTrigger>Product</NavigationMenuTrigger>
          <NavigationMenuContent>
            <div className="w-80 p-4">
              <NavigationMenuLink
                description="See your workspace at a glance."
                href="#overview"
              >
                Overview
              </NavigationMenuLink>
            </div>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem>
          <NavigationMenuLink href="#pricing">Pricing</NavigationMenuLink>
        </NavigationMenuItem>
      </NavigationMenuList>
      <NavigationMenuPopup anchor={anchor} />
    </NavigationMenu>
  );
}

Composition

NavigationMenu
├── NavigationMenuList
│   ├── NavigationMenuItem
│   │   ├── NavigationMenuTrigger
│   │   └── NavigationMenuContent
│   │       └── NavigationMenuLink (panel row)
│   └── NavigationMenuItem
│       └── NavigationMenuLink
└── NavigationMenuPopup (shared positioner, popup and viewport)

A link inside NavigationMenuContent takes the full-width panel row style on its own, so it needs no popup flag, and a link beside the triggers keeps the style of the bar. Links rest in the secondary text color, and hover draws a light fill. The link of the current page, marked with active, keeps a persistent fill and the full foreground, the same as the current row of a Sidebar.

Header Navigation builds its desktop links from these parts.

Multi-column panels

Content is plain markup, so a panel can hold several labeled groups. Give the content element a width, and keep it max-w-full so it never outgrows the screen. Moving between triggers keeps one popup open: it resizes to the new content while the old and the new panel cross-fade.

"use client";

import { useRef } from "react";
import { Badge } from "@/siteplane/components/badge";
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuPopup,
  NavigationMenuTrigger,
} from "@/siteplane/components/navigation-menu";

const groups = [
  {
    label: "Workspace",
    links: [
      {
        description: "See what is happening across your workspace.",
        href: "#overview",
        label: "Overview",
      },
      {
        description: "Updates that need your attention.",
        href: "#notifications",
        label: "Notifications",
      },
      {
        description: "Understand how your work performs.",
        href: "#analytics",
        label: "Analytics",
        isNew: true,
      },
    ],
  },
  {
    label: "Reports",
    links: [
      {
        description: "Return to your saved reports.",
        href: "#saved",
        label: "Saved",
      },
      {
        description: "Manage recurring reports.",
        href: "#scheduled",
        label: "Scheduled",
      },
      {
        description: "Explore activity and engagement.",
        href: "#user-reports",
        label: "User reports",
      },
    ],
  },
];

export default function Example() {
  const anchor = useRef<HTMLUListElement>(null);

  return (
    <NavigationMenu aria-label="Product navigation">
      <NavigationMenuList ref={anchor}>
        <NavigationMenuItem value="dashboard">
          <NavigationMenuTrigger>Dashboard</NavigationMenuTrigger>
          <NavigationMenuContent>
            <div className="grid w-[36rem] max-w-full grid-cols-2 gap-6 p-6">
              {groups.map((group) => (
                <div
                  className="grid min-w-0 content-start gap-2"
                  key={group.label}
                >
                  <h3 className="text-xs font-medium text-muted-foreground">
                    {group.label}
                  </h3>
                  <ul className="grid gap-1">
                    {group.links.map((link) => (
                      <li key={link.href}>
                        <NavigationMenuLink
                          description={link.description}
                          href={link.href}
                        >
                          {link.label}
                          {link.isNew ? (
                            <Badge size="sm" variant="accent">
                              New
                            </Badge>
                          ) : null}
                        </NavigationMenuLink>
                      </li>
                    ))}
                  </ul>
                </div>
              ))}
            </div>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem>
          <NavigationMenuLink href="#projects">Projects</NavigationMenuLink>
        </NavigationMenuItem>
        <NavigationMenuItem>
          <NavigationMenuLink href="#team">Team</NavigationMenuLink>
        </NavigationMenuItem>
      </NavigationMenuList>
      <NavigationMenuPopup anchor={anchor} />
    </NavigationMenu>
  );
}

Controlled value

Pass value and onValueChange to open and close panels from your own state. Match value to the value of a NavigationMenuItem; null closes the menu.

Open item: none

"use client";

import { useRef, useState } from "react";
import { Button } from "@/siteplane/components/button";
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuPopup,
  NavigationMenuTrigger,
} from "@/siteplane/components/navigation-menu";

export default function Example() {
  const anchor = useRef<HTMLUListElement>(null);
  const [value, setValue] = useState<string | null>(null);

  return (
    <div className="grid justify-items-start gap-4">
      <div className="flex flex-wrap items-center gap-2">
        <Button onClick={() => setValue("resources")} variant="outline">
          Open Resources
        </Button>
        <Button onClick={() => setValue(null)} variant="ghost">
          Close
        </Button>
        <p className="text-sm text-muted-foreground">
          Open item: {value ?? "none"}
        </p>
      </div>
      <NavigationMenu
        aria-label="Main navigation"
        onValueChange={setValue}
        value={value}
      >
        <NavigationMenuList ref={anchor}>
          <NavigationMenuItem value="product">
            <NavigationMenuTrigger>Product</NavigationMenuTrigger>
            <NavigationMenuContent>
              <div className="grid w-80 max-w-full gap-1 p-4">
                <NavigationMenuLink
                  description="See what is happening across your workspace."
                  href="#overview"
                >
                  Overview
                </NavigationMenuLink>
                <NavigationMenuLink
                  description="Track work from planning to launch."
                  href="#projects"
                >
                  Projects
                </NavigationMenuLink>
              </div>
            </NavigationMenuContent>
          </NavigationMenuItem>
          <NavigationMenuItem value="resources">
            <NavigationMenuTrigger>Resources</NavigationMenuTrigger>
            <NavigationMenuContent>
              <div className="grid w-80 max-w-full gap-1 p-4">
                <NavigationMenuLink
                  description="Guides and references for every feature."
                  href="#documentation"
                >
                  Documentation
                </NavigationMenuLink>
                <NavigationMenuLink
                  description="The latest improvements and releases."
                  href="#changelog"
                >
                  Changelog
                </NavigationMenuLink>
              </div>
            </NavigationMenuContent>
          </NavigationMenuItem>
        </NavigationMenuList>
        <NavigationMenuPopup anchor={anchor} />
      </NavigationMenu>
    </div>
  );
}

API reference

Component properties
PropTypeDefaultDescription
NavigationMenuBase UI NavigationMenu.Root propsnavNavigation landmark. Give it an aria-label; the other props follow Base UI.
NavigationMenu.value / defaultValuestring | nullnullControlled or initial open item, matched to NavigationMenuItem value. null means closed.
NavigationMenu.onValueChange(value, eventDetails) => voidunsetCalled when an item opens or closes.
NavigationMenu.delay / closeDelaynumber50 / 50Milliseconds before hover opens or closes a panel.
NavigationMenuListul propsflex row, 4 px gapRow of items. Pass a ref to anchor the popup to the whole row.
NavigationMenuItem.valuestringgeneratedIdentifies the item. Set it when you control the menu.
NavigationMenuTriggerBase UI Trigger propsghost Button, navigation-link profileOpens the item's panel on hover or press and adds a chevron that turns while it is open.
NavigationMenuLink.hrefstringunsetDestination of the anchor.
NavigationMenuLink.activebooleanfalseMarks the current page with aria-current page and shows the persistent fill with the full foreground.
NavigationMenuLink.popupbooleantrue inside NavigationMenuContent, false elsewherePanel row style: full width and aligned to the start. Set false for a bar link inside a panel.
NavigationMenuLink.descriptionReactNodeunsetSupporting text under the label. Beside a leading icon it starts under the label text.
NavigationMenuLink.closeOnClickbooleantrueCloses the open panel when the link is pressed.
NavigationMenuContent.keepMountedbooleanfalseKeeps the panel in the DOM while closed, so it exists in server-rendered markup.
NavigationMenuPopup.anchorBase UI Positioner anchortriggerElement the shared popup aligns to. Pass the list ref to align it with the row.

Parts accept the props of their Base UI counterparts and string className. Trigger and link render Buttons, so they share the Button's focus, hover and pressed states. A render prop on either replaces the element inside that Button, such as the link's anchor with your router's link, and the Button keeps its styling; a render function receives the part's state. The popup renders in a portal 8 px below the row, aligned to its start edge, with 16 px of collision padding. Render exactly one NavigationMenuPopup inside NavigationMenu.

Read the installed source.

Usage notes

Use a Navigation Menu for site-level destinations with panels of links. Use a Menu for actions, and Tabs to switch content within a page. Triggers only open panels; put every destination inside a panel or beside the triggers as a link.

The Left and Right arrow keys move between the triggers and links of the row. Enter or Space on a trigger opens its panel and moves focus to the first link, the Up and Down arrow keys move through the links, and Escape closes the panel and returns focus to the trigger. Set active on the link of the current page so it is announced with aria-current="page" and keeps its persistent fill.

Use real destination URLs in your application. These examples use fragment destinations to keep the preview local. Opening and switching panels fades and moves the content a few pixels; reduced motion removes the movement.