# 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"`
- **Radix Navigation Menu:** <https://www.radix-ui.com/primitives/docs/components/navigation-menu>
- **APG Disclosure Navigation:** <https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/>
- **Page:** <https://ui.booleanpress.com/components/navigation-menu> · @booleanpress/ui 0.2.0

## Usage

A navigation menu is for moving between pages: its panels hold links, not commands. For commands, use a [menubar](/components/menubar) or a [dropdown menu](/components/dropdown-menu).

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

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

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

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

```tsx
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

| Key | Behaviour |
| --- | --- |
| Tab | Moves to the next trigger or link; from an open trigger, into its panel. |
| Enter or Space | On 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). |
| Home or End | Moves to the first or last trigger or link in the row. |
| Escape | Closes the open panel and returns focus to its trigger. |

## API

### NavigationMenu

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `defaultValue` | `string` |  | The panel open at the start, when it controls itself. |
| `delayDuration` | `number` |  | The 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. |
| `skipDelayDuration` | `number` |  | How much time a user has to enter another trigger without incurring a delay again. |
| `value` | `string` |  | The open panel's value, when you control it. Pair it with `onValueChange`. |
| `viewport` | `boolean` | `true` | `true` (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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |

### NavigationMenuItem

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `value` | `string` |  | The value that names this item, for a controlled menu or a `NavigationMenuSub`. |

### NavigationMenuContent

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `deferPointerDownOutside` | `boolean` |  | When `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. |
| `forceMount` | `true` |  | Used 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  | Render 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `active` | `boolean` |  | Marks the link to the current page, with `aria-current="page"`. |
| `asChild` | `boolean` |  | Render 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `forceMount` | `true` |  | Used 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `forceMount` | `true` |  | Used 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `defaultValue` | `string` |  | The 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. |
| `value` | `string` |  | The 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`.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--accent-foreground` | text |
| `--border` | background |
| `--control-hover` | text |
| `--muted-foreground` | text |
| `--popover` | background |
| `--popover-foreground` | text |
| `--ring` | outline |
