Skip to the content

ComponentsMenu

Navigation menu

A row of links to a site's or an app's sections, where some open a panel of further links.

Import

import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuContent, NavigationMenuTrigger, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, NavigationMenuSub } from "@booleanpress/ui/navigation-menu"

Usage

A navigation menu is for moving between pages: its panels hold links, not commands. For commands, use a menubar or a dropdown menu.

import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
} from "@booleanpress/ui/navigation-menu"

export function SiteNav() {
  return (
    <NavigationMenu>
      <NavigationMenuList>
        <NavigationMenuItem>
          <NavigationMenuTrigger>Logs</NavigationMenuTrigger>
          <NavigationMenuContent>
            <NavigationMenuLink href="/logs/delivery">Delivery log</NavigationMenuLink>
          </NavigationMenuContent>
        </NavigationMenuItem>
      </NavigationMenuList>
    </NavigationMenu>
  )
}

Each NavigationMenuItem holds either a NavigationMenuTrigger with its NavigationMenuContent, or a plain NavigationMenuLink; give a top-level link className={navigationMenuTriggerStyle()} so it looks like the triggers. Panels open on hover and on click. By default every panel is shown in one shared viewport under the whole menu, which resizes and slides between panels; viewport={false} shows each panel under its own trigger instead. active on a link marks the current page (aria-current="page"). The panel's layout is yours: a list of links, a grid of groups for a mega menu, or a NavigationMenuSub for a second level that works like tabs. A link is a column (flex-col) for a title above a description; add flex-row items-center for an icon beside the text.

Examples

Basic

Three panels of links and a plain link styled as a trigger.

import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
  navigationMenuTriggerStyle,
} from "@booleanpress/ui/navigation-menu"

const SECTIONS = [
  { title: "Sending", links: ["Connections", "Routing rules", "Suppression list"] },
  { title: "Logs", links: ["Delivery log", "Bounces", "Webhook events"] },
  { title: "Settings", links: ["General", "Notifications", "API keys"] },
]

export default function NavigationMenuBasic() {
  return (
    <div className="flex h-44 w-full items-start justify-center">
      <NavigationMenu>
        <NavigationMenuList>
          {SECTIONS.map((section) => (
            <NavigationMenuItem key={section.title}>
              <NavigationMenuTrigger>{section.title}</NavigationMenuTrigger>
              <NavigationMenuContent>
                <ul className="flex w-48 flex-col gap-0.5">
                  {section.links.map((link) => (
                    <li key={link}>
                      <NavigationMenuLink href={`#${link.toLowerCase().replaceAll(" ", "-")}`}>{link}</NavigationMenuLink>
                    </li>
                  ))}
                </ul>
              </NavigationMenuContent>
            </NavigationMenuItem>
          ))}
          <NavigationMenuItem>
            <NavigationMenuLink href="#help" className={navigationMenuTriggerStyle()}>
              Help
            </NavigationMenuLink>
          </NavigationMenuItem>
        </NavigationMenuList>
      </NavigationMenu>
    </div>
  )
}

Icons

Icons in the triggers and the links; viewport={false} puts each panel under its trigger.

import { ActivityIcon, BellIcon, KeyRoundIcon, LifeBuoyIcon, MailIcon, PlugIcon, ScrollTextIcon, SettingsIcon } from "lucide-react"
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
  navigationMenuTriggerStyle,
} from "@booleanpress/ui/navigation-menu"

const SECTIONS = [
  { title: "Sending", icon: MailIcon, links: [{ label: "Connections", icon: PlugIcon }, { label: "Delivery log", icon: ScrollTextIcon }] },
  { title: "Monitoring", icon: ActivityIcon, links: [{ label: "Alerts", icon: BellIcon }, { label: "Reports", icon: ActivityIcon }] },
  { title: "Settings", icon: SettingsIcon, links: [{ label: "API keys", icon: KeyRoundIcon }, { label: "General", icon: SettingsIcon }] },
]

export default function NavigationMenuIcons() {
  return (
    <div className="flex h-40 w-full items-start justify-center">
      <NavigationMenu viewport={false}>
        <NavigationMenuList>
          {SECTIONS.map(({ title, icon: Icon, links }) => (
            <NavigationMenuItem key={title}>
              <NavigationMenuTrigger>
                <Icon />
                {title}
              </NavigationMenuTrigger>
              <NavigationMenuContent>
                <ul className="flex w-44 flex-col gap-0.5">
                  {links.map(({ label, icon: LinkIcon }) => (
                    <li key={label}>
                      <NavigationMenuLink href={`#${label.toLowerCase().replaceAll(" ", "-")}`} className="flex-row items-center gap-2">
                        <LinkIcon />
                        {label}
                      </NavigationMenuLink>
                    </li>
                  ))}
                </ul>
              </NavigationMenuContent>
            </NavigationMenuItem>
          ))}
          <NavigationMenuItem>
            <NavigationMenuLink href="#support" className={navigationMenuTriggerStyle()}>
              <LifeBuoyIcon />
              Support
            </NavigationMenuLink>
          </NavigationMenuItem>
        </NavigationMenuList>
      </NavigationMenu>
    </div>
  )
}

Submenus

NavigationMenuSub with a vertical list: each area shows its own links beside it.

import { ChevronRightIcon } from "lucide-react"
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuSub,
  NavigationMenuTrigger,
} from "@booleanpress/ui/navigation-menu"

const AREAS = [
  { value: "sending", title: "Sending", links: ["Connections", "Routing rules", "Suppression list"] },
  { value: "logs", title: "Logs", links: ["Delivery log", "Bounces", "Webhook events"] },
  { value: "team", title: "Team", links: ["Members", "Roles", "Audit trail"] },
]

export default function NavigationMenuSubmenus() {
  return (
    <div className="flex h-48 w-full items-start justify-center">
      <NavigationMenu className="w-[25rem] max-w-none flex-none">
        <NavigationMenuList>
          <NavigationMenuItem>
            <NavigationMenuTrigger>Workspace</NavigationMenuTrigger>
            <NavigationMenuContent>
              <NavigationMenuSub defaultValue="sending" orientation="vertical" className="relative h-28 w-96">
                <NavigationMenuList className="w-40 flex-col items-stretch gap-0.5">
                  {AREAS.map((area) => (
                    <NavigationMenuItem key={area.value} value={area.value} className="static">
                      <NavigationMenuTrigger className="w-full justify-between font-normal">
                        {area.title}
                        <ChevronRightIcon className="size-3 text-control-hover rtl:rotate-180" />
                      </NavigationMenuTrigger>
                      <NavigationMenuContent className="start-40 w-56 ps-2 md:w-56">
                        <ul className="flex flex-col gap-0.5 border-s ps-2">
                          {area.links.map((link) => (
                            <li key={link}>
                              <NavigationMenuLink href={`#${link.toLowerCase().replaceAll(" ", "-")}`}>{link}</NavigationMenuLink>
                            </li>
                          ))}
                        </ul>
                      </NavigationMenuContent>
                    </NavigationMenuItem>
                  ))}
                </NavigationMenuList>
              </NavigationMenuSub>
            </NavigationMenuContent>
          </NavigationMenuItem>
        </NavigationMenuList>
      </NavigationMenu>
    </div>
  )
}

Mega menu

A wide panel of grouped links with a highlighted link across the bottom.

import { LifeBuoyIcon, MailIcon, MousePointerClickIcon } from "lucide-react"
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
  navigationMenuTriggerStyle,
} from "@booleanpress/ui/navigation-menu"

const GROUPS = [
  { title: "Sending", icon: MailIcon, links: ["SMTP relay", "Email API", "Templates", "Webhooks"] },
  { title: "Tracking", icon: MousePointerClickIcon, links: ["Opens", "Clicks", "Bounces", "Complaints"] },
  { title: "Support", icon: LifeBuoyIcon, links: ["Help desk", "Knowledge base", "Live chat", "Status page"] },
]

const slug = (text: string) => `#${text.toLowerCase().replaceAll(" ", "-")}`

export default function NavigationMenuMegaMenu() {
  return (
    <div className="flex h-80 w-full items-start justify-center">
      <NavigationMenu className="w-[34rem] max-w-none flex-none">
        <NavigationMenuList>
          <NavigationMenuItem>
            <NavigationMenuTrigger>Products</NavigationMenuTrigger>
            <NavigationMenuContent>
              <div className="grid w-[33rem] grid-cols-3 gap-x-2 gap-y-1 p-1">
                {GROUPS.map(({ title, icon: Icon, links }) => (
                  <div key={title} className="flex flex-col gap-0.5">
                    <p className="flex items-center gap-2 px-2.5 py-1 text-sm/normal font-semibold text-muted-foreground">
                      <Icon aria-hidden="true" className="size-3.5" />
                      {title}
                    </p>
                    <ul className="flex flex-col gap-0.5">
                      {links.map((link) => (
                        <li key={link}>
                          <NavigationMenuLink href={slug(link)}>{link}</NavigationMenuLink>
                        </li>
                      ))}
                    </ul>
                  </div>
                ))}
                <NavigationMenuLink href="#inbound-routing" className="col-span-3 mt-1 bg-muted px-4 py-3">
                  <span className="font-semibold text-foreground">New: inbound routing</span>
                  <span className="text-muted-foreground">Send replies to the right help desk queue.</span>
                </NavigationMenuLink>
              </div>
            </NavigationMenuContent>
          </NavigationMenuItem>
          <NavigationMenuItem>
            <NavigationMenuLink href="#pricing" className={navigationMenuTriggerStyle()}>Pricing</NavigationMenuLink>
          </NavigationMenuItem>
          <NavigationMenuItem>
            <NavigationMenuLink href="#docs" className={navigationMenuTriggerStyle()}>Docs</NavigationMenuLink>
          </NavigationMenuItem>
        </NavigationMenuList>
      </NavigationMenu>
    </div>
  )
}

Accessibility

Semantics
The menu is a nav holding a list. Each trigger is a button with aria-expanded and aria-controls; links are a elements, and the current page's link has aria-current="page". It is a disclosure navigation, not an ARIA menu: there are no menu roles.
Labels
Triggers and links are named by their text. Give the menu an aria-label when the page has more than one nav.
Focus
Triggers and top-level links are tab stops; Tab from an open trigger moves into its panel. Keyboard focus is a 1px --ring outline 2px away on triggers and top-level links, and the --accent fill on links inside a panel.
Known limits
  • The triggers carry no chevron, as the visual target; the state is in aria-expanded. Add an icon in the trigger if your users need the cue.
  • Panels open on hover. A panel's links must not be the only way to reach those pages on touch devices: a tap opens the panel, a second tap on a link follows it.
  • With the shared viewport, every panel opens under the menu's start edge (its right edge in right-to-left pages), not under its trigger; viewport={false} puts each panel under its trigger.

Keyboard

Keyboard
KeyBehaviour
TabMoves to the next trigger or link; from an open trigger, into its panel.
EnterorSpaceOn a trigger, opens or closes its panel. On a link, follows it.
โ†“On an open trigger, moves into its panel.
โ†’Moves to the next trigger or link in the row (โ† in right-to-left pages).
โ†Moves to the previous trigger or link in the row (โ†’ in right-to-left pages).
HomeorEndMoves to the first or last trigger or link in the row.
EscapeCloses the open panel and returns focus to its trigger.

API

NavigationMenu

Renders Radix NavigationMenu.Root and passes it every other prop.

NavigationMenu props
PropTypeDefaultDescription
asChildboolean
defaultValuestringThe panel open at the start, when it controls itself.
delayDurationnumberThe duration from when the pointer enters the trigger until the tooltip gets opened.
dir"ltr" | "rtl"The reading direction. The provider's dir by default.
onValueChange((value: string) => void)Called with the open item's value, or an empty string when it closes.
orientation"horizontal" | "vertical"horizontal (default) or vertical: which arrow keys move between items.
skipDelayDurationnumberHow much time a user has to enter another trigger without incurring a delay again.
valuestringThe open panel's value, when you control it. Pair it with onValueChange.
viewportbooleantruetrue (default) shows panels in one shared viewport under the menu; false under each trigger.

NavigationMenuList

Renders Radix NavigationMenu.List and passes it every other prop.

NavigationMenuList props
PropTypeDefaultDescription
asChildboolean

NavigationMenuItem

Renders Radix NavigationMenu.Item and passes it every other prop.

NavigationMenuItem props
PropTypeDefaultDescription
asChildboolean
valuestringThe value that names this item, for a controlled menu or a NavigationMenuSub.

NavigationMenuContent

Renders Radix NavigationMenu.Content and passes it every other prop.

NavigationMenuContent props
PropTypeDefaultDescription
asChildboolean
deferPointerDownOutsidebooleanWhen true, a 'pointerdown' event outside of the layered element will wait for the interaction's click event before dispatching, allowing third-party code to stop propagation of later events and cancel dismissal.
forceMounttrueUsed to force mounting when more control is needed. Useful when controlling animation with React animation libraries.
onEscapeKeyDown((event: KeyboardEvent) => void)Event handler called when the escape key is down. Can be prevented.
onFocusOutside((event: FocusOutsideEvent) => void)Event handler called when the focus moves outside of the DismissableLayer. Can be prevented.
onInteractOutside((event: FocusOutsideEvent | PointerDownOutsideEvent) => void)Event handler called when an interaction happens outside the DismissableLayer. Specifically, when a pointerdown event happens outside or focus moves outside of it. Can be prevented.
onPointerDownOutside((event: PointerDownOutsideEvent) => void)Event handler called when the a pointerdown event happens outside of the DismissableLayer. Can be prevented.

NavigationMenuTrigger

Renders Radix NavigationMenu.Trigger and passes it every other prop.

NavigationMenuTrigger props
PropTypeDefaultDescription
asChildbooleanRender the child element instead, with this part's behaviour and classes merged onto it.

NavigationMenuLink

Renders Radix NavigationMenu.Link and passes it every other prop.

NavigationMenuLink props
PropTypeDefaultDescription
activebooleanMarks the link to the current page, with aria-current="page".
asChildbooleanRender the child element instead, with this part's behaviour and classes merged onto it.
onSelect((event: Event) => void)Called when the link is followed. Call event.preventDefault() to keep the panel open.

NavigationMenuIndicator

Renders Radix NavigationMenu.Indicator and passes it every other prop.

NavigationMenuIndicator props
PropTypeDefaultDescription
asChildboolean
forceMounttrueUsed to force mounting when more control is needed. Useful when controlling animation with React animation libraries.

NavigationMenuViewport

Renders Radix NavigationMenu.Viewport and passes it every other prop.

NavigationMenuViewport props
PropTypeDefaultDescription
asChildboolean
forceMounttrueUsed to force mounting when more control is needed. Useful when controlling animation with React animation libraries.

NavigationMenuSub

Renders Radix NavigationMenu.Sub and passes it every other prop.

NavigationMenuSub props
PropTypeDefaultDescription
asChildboolean
defaultValuestringThe item shown at the start. One item is always shown, as in tabs.
onValueChange((value: string) => void)Called with the value of the item shown.
orientation"horizontal" | "vertical"horizontal (default) or vertical: which arrow keys move between its triggers.
valuestringThe shown item's value, when you control it. Pair it with onValueChange.

Also exported: navigationMenuTriggerStyle, a helper the parts use.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="navigation-menu" (NavigationMenu), data-slot="navigation-menu-list" (NavigationMenuList), data-slot="navigation-menu-item" (NavigationMenuItem), data-slot="navigation-menu-content" (NavigationMenuContent), data-slot="navigation-menu-trigger" (NavigationMenuTrigger), data-slot="navigation-menu-link" (NavigationMenuLink), data-slot="navigation-menu-indicator" (NavigationMenuIndicator), data-slot="navigation-menu-viewport-wrapper" (NavigationMenuViewport), data-slot="navigation-menu-sub" (NavigationMenuSub), and data-viewport.

Theming

Triggers have no fill at rest and --accent on hover, focus and while open. Panels are --popover with a --border edge; links use --accent when hovered or focused, and their icons --control-hover, then --muted-foreground.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accentbackground
--accent-foregroundtext
--borderbackground
--control-hovertext
--muted-foregroundtext
--popoverbackground
--popover-foregroundtext
--ringoutline