Navigation Menu

A collection of links for navigating websites.

import type { ReactNode } from "react"
import * as stylex from "@stylexjs/stylex"
import { CircleAlert, CircleCheck, CircleDashed } from "lucide-react"
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
  navigationMenuStyles,
} from "@/components/ui/navigation-menu"
import { VStack } from "@/components/ui/stack"
import { breakpoints } from "@/theme/breakpoints.stylex"
import { color, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  list: { width: "24rem" },
  grid: {
    display: "grid",
    gap: space.s2,
    width: { default: "25rem", [breakpoints.md]: "31.25rem", [breakpoints.lg]: "37.5rem" },
    gridTemplateColumns: { default: "1fr", [breakpoints.md]: "repeat(2, 1fr)" },
  },
  narrow: { width: "12.5rem" },
  wideOnly: { display: { default: "none", [breakpoints.md]: "flex" } },
  title: { fontWeight: 500, lineHeight: 1 },
  description: {
    display: "-webkit-box",
    WebkitLineClamp: 2,
    WebkitBoxOrient: "vertical",
    overflow: "hidden",
    color: color.mutedForeground,
  },
})

const components: { title: string; href: string; description: string }[] = [
  {
    title: "Alert Dialog",
    href: "/docs/components/alert-dialog",
    description:
      "A modal dialog that interrupts the user with important content and expects a response.",
  },
  {
    title: "Hover Card",
    href: "/docs/components/hover-card",
    description: "For sighted users to preview content available behind a link.",
  },
  {
    title: "Progress",
    href: "/docs/components/progress",
    description:
      "Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.",
  },
  {
    title: "Scroll Area",
    href: "/docs/components/scroll-area",
    description: "Visually or semantically separates content.",
  },
  {
    title: "Tabs",
    href: "/docs/components/tabs",
    description:
      "A set of layered sections of content, known as tab panels, that are displayed one at a time.",
  },
  {
    title: "Tooltip",
    href: "/docs/components/tooltip",
    description:
      "A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.",
  },
]

export default function NavigationMenuDemo() {
  return (
    <NavigationMenu>
      <NavigationMenuList>
        <NavigationMenuItem>
          <NavigationMenuTrigger>Getting started</NavigationMenuTrigger>
          <NavigationMenuContent>
            <ul {...stylex.props(styles.list)}>
              <ListItem href="/docs/installation" title="Introduction">
                Re-usable components built with Base UI and StyleX.
              </ListItem>
              <ListItem href="/docs/installation" title="Installation">
                How to install dependencies and structure your app.
              </ListItem>
              <ListItem href="/docs/components/stack" title="Stack">
                Direction, gap, padding and alignment on one closed scale.
              </ListItem>
            </ul>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem xstyle={styles.wideOnly}>
          <NavigationMenuTrigger>Components</NavigationMenuTrigger>
          <NavigationMenuContent>
            <ul {...stylex.props(styles.grid)}>
              {components.map((component) => (
                <ListItem key={component.title} title={component.title} href={component.href}>
                  {component.description}
                </ListItem>
              ))}
            </ul>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem>
          <NavigationMenuTrigger>With Icon</NavigationMenuTrigger>
          <NavigationMenuContent>
            <ul {...stylex.props(styles.narrow)}>
              <li>
                <NavigationMenuLink href="#">
                  <CircleAlert />
                  Backlog
                </NavigationMenuLink>
                <NavigationMenuLink href="#">
                  <CircleDashed />
                  To Do
                </NavigationMenuLink>
                <NavigationMenuLink href="#">
                  <CircleCheck />
                  Done
                </NavigationMenuLink>
              </li>
            </ul>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem>
          <NavigationMenuLink href="/docs/installation" xstyle={navigationMenuStyles.trigger}>
            Docs
          </NavigationMenuLink>
        </NavigationMenuItem>
      </NavigationMenuList>
    </NavigationMenu>
  )
}

function ListItem({ title, children, href }: { title: string; children: ReactNode; href: string }) {
  return (
    <li>
      <NavigationMenuLink href={href}>
        <VStack gap={1}>
          <span {...stylex.props(styles.title)}>{title}</span>
          <span {...stylex.props(styles.description)}>{children}</span>
        </VStack>
      </NavigationMenuLink>
    </li>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx lucide-react

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. states.ts carries the popup's surface, the positioner's layer and the focus ring that a trigger and a link share. state-styles.ts is how every part reads its Base UI state, such as the open trigger, the sliding panel and the active link. The menu also needs interop.css to size the icons a link carries.

Copy and paste the following code into your project.

components/ui/navigation-menu.tsx
"use client"

import { createContext, use } from "react"
import type { CSSProperties } from "react"
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { ChevronDown } from "lucide-react"

import { stateStyles } from "@/lib/state-styles"
import { states } from "@/theme/states"
import {
  color,
  duration,
  ease,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

const styles = stylex.create({
  root: {
    position: "relative",
    display: "flex",
    maxWidth: "max-content",
    alignItems: "center",
    justifyContent: "center",
  },
  list: {
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    gap: space.s1,
  },
  listVertical: { flexDirection: "column", alignItems: "stretch" },
  item: { position: "relative" },
  trigger: {
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    gap: space.s1,
    height: space.s8,
    width: "max-content",
    borderRadius: radius.lg,
    paddingInline: space.s2_5,
    paddingBlock: space.s1_5,
    backgroundColor: {
      default: `var(--carbon-nav-current-bg, ${color.transparent})`,
      ":hover": color.itemHighlightBg,
    },
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontWeight: 500,
    transitionProperty: "color, background-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    pointerEvents: { default: null, ":disabled": "none" },
    opacity: { default: 1, ":disabled": 0.5 },
    "--carbon-icon-size": space.s4,
  },
  surface: {
    boxShadow: {
      default: `var(--carbon-nav-current-ring, inset 0 0 0 0 ${color.transparent})`,
      ":focus-visible": `var(--carbon-nav-current-ring, inset 0 0 0 0 ${color.transparent}), ${shadow.ring}`,
    },
  },
  current: {
    "--carbon-nav-current-bg": color.itemHighlightBg,
    "--carbon-nav-current-ring": `inset 0 0 0 1px ${color.itemHighlightRing}`,
  },
  chevron: {
    display: "inline-flex",
    position: "relative",
    top: 1,
    "--carbon-icon-size": space.s3,
    transitionProperty: "transform",
    transitionDuration: { default: duration.move, "@media (prefers-reduced-motion: reduce)": "0s" },
    transitionTimingFunction: ease.standard,
  },
  chevronOpen: { transform: "rotate(180deg)" },
  link: {
    display: "flex",
    alignItems: "center",
    gap: space.s2,
    borderRadius: radius.lg,
    paddingInline: space.s2,
    paddingBlock: space.s2,
    backgroundColor: {
      default: `var(--carbon-nav-current-bg, ${color.transparent})`,
      ":hover": color.itemHighlightBg,
    },
    fontSize: text.sm,
    lineHeight: leading.sm,
    transitionProperty: "color, background-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "--carbon-icon-size": space.s4,
  },
  content: {
    width: "auto",
    height: "100%",
    padding: space.s1,
    transitionProperty: "opacity, transform",
    transitionDuration: {
      default: `150ms, ${duration.panel}`,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: `${ease.standard}, ${ease.decelerate}`,
  },
  hidden: { opacity: 0 },
  positioner: {
    width: "var(--positioner-width)",
    height: "var(--positioner-height)",
    maxWidth: "var(--available-width)",
    transitionProperty: "top, left, right, bottom",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.decelerate,
    "::before": { content: "''", position: "absolute" },
  },
  positionerInstant: { transitionDuration: "0s" },
  popup: {
    position: "relative",
    width: "var(--popup-width)",
    height: "var(--popup-height)",
    "--carbon-popup-radius": radius.lg,
    borderRadius: "var(--carbon-popup-radius)",
    transformOrigin: "var(--transform-origin)",
    transitionProperty: "opacity, transform, width, height",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.decelerate,
  },
  popupHidden: { opacity: 0, transform: "scale(0.95)" },
  popupEnding: {
    transitionDuration: { default: "150ms", "@media (prefers-reduced-motion: reduce)": "0s" },
    transitionTimingFunction: ease.standard,
  },
  viewport: { position: "relative", width: "100%", height: "100%", overflow: "hidden" },
})

const bridge = stylex.create({
  bottom: {
    "::before": { top: `calc(-1 * ${space.s2_5})`, right: 0, left: 0, height: space.s2_5 },
  },
  top: {
    "::before": { bottom: `calc(-1 * ${space.s2_5})`, right: 0, left: 0, height: space.s2_5 },
  },
  left: {
    "::before": { top: 0, bottom: 0, right: `calc(-1 * ${space.s2_5})`, width: space.s2_5 },
  },
  right: {
    "::before": { top: 0, bottom: 0, left: `calc(-1 * ${space.s2_5})`, width: space.s2_5 },
  },
  "inline-start": {
    "::before": { top: 0, bottom: 0, right: `calc(-1 * ${space.s2_5})`, width: space.s2_5 },
  },
  "inline-end": {
    "::before": { top: 0, bottom: 0, left: `calc(-1 * ${space.s2_5})`, width: space.s2_5 },
  },
})

const slide = stylex.create({
  left: { transform: "translateX(-50%)" },
  right: { transform: "translateX(50%)" },
  up: { transform: "translateY(-50%)" },
  down: { transform: "translateY(50%)" },
})

const opposite = { left: "right", right: "left", up: "down", down: "up" } as const

type Orientation = "horizontal" | "vertical"

type Overrides = { xstyle?: StyleXStyles; className?: string }

type StateOverrides = Overrides & { style?: CSSProperties }

const NavigationMenuOrientationContext = createContext<Orientation>("horizontal")

const NavigationMenuContentContext = createContext(false)

type NavigationMenuPositionerProps = Omit<
  NavigationMenuPrimitive.Positioner.Props,
  "className" | "style"
> & {
  style?: CSSProperties
  popupProps?: Omit<NavigationMenuPrimitive.Popup.Props, "className" | "style"> & StateOverrides
}

function NavigationMenuPositioner({
  side = "bottom",
  sideOffset = 8,
  align = "start",
  alignOffset = 0,
  popupProps,
  style,
  ...props
}: NavigationMenuPositionerProps) {
  const { xstyle, className, style: popupStyle, ...popup } = popupProps ?? {}

  return (
    <NavigationMenuPrimitive.Portal>
      <NavigationMenuPrimitive.Positioner
        data-slot="navigation-menu-positioner"
        side={side}
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
        {...props}
        {...stateStyles(
          (state) => [
            states.positioner,
            styles.positioner,
            bridge[state.side],
            state.instant && styles.positionerInstant,
          ],
          { style },
        )}
      >
        <NavigationMenuPrimitive.Popup
          data-slot="navigation-menu-popup"
          {...popup}
          {...stateStyles(
            (state) => [
              states.popupSurface,
              styles.popup,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                styles.popupHidden,
              state.transitionStatus === "ending" && styles.popupEnding,
              xstyle,
            ],
            { className, style: popupStyle },
          )}
        >
          <NavigationMenuPrimitive.Viewport
            data-slot="navigation-menu-viewport"
            {...stylex.props(styles.viewport)}
          />
        </NavigationMenuPrimitive.Popup>
      </NavigationMenuPrimitive.Positioner>
    </NavigationMenuPrimitive.Portal>
  )
}

type NavigationMenuProps<Value> = Omit<NavigationMenuPrimitive.Root.Props<Value>, "className"> &
  Pick<
    NavigationMenuPositionerProps,
    "side" | "sideOffset" | "align" | "alignOffset" | "popupProps"
  > &
  Overrides

function NavigationMenu<Value = any>({
  orientation = "horizontal",
  side = orientation === "vertical" ? "right" : "bottom",
  sideOffset,
  align,
  alignOffset,
  popupProps,
  xstyle,
  className,
  children,
  ...props
}: NavigationMenuProps<Value>) {
  const sx = stylex.props(styles.root, xstyle)

  return (
    <NavigationMenuOrientationContext value={orientation}>
      <NavigationMenuPrimitive.Root
        data-slot="navigation-menu"
        data-orientation={orientation}
        orientation={orientation}
        {...sx}
        {...props}
        className={clsx(sx.className, className)}
      >
        {children}
        <NavigationMenuPositioner
          side={side}
          sideOffset={sideOffset}
          align={align}
          alignOffset={alignOffset}
          popupProps={popupProps}
        />
      </NavigationMenuPrimitive.Root>
    </NavigationMenuOrientationContext>
  )
}

type NavigationMenuListProps = Omit<NavigationMenuPrimitive.List.Props, "className"> & Overrides

function NavigationMenuList({ xstyle, className, ...props }: NavigationMenuListProps) {
  const orientation = use(NavigationMenuOrientationContext)
  const sx = stylex.props(styles.list, orientation === "vertical" && styles.listVertical, xstyle)

  return (
    <NavigationMenuPrimitive.List
      data-slot="navigation-menu-list"
      data-orientation={orientation}
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type NavigationMenuItemProps = Omit<NavigationMenuPrimitive.Item.Props, "className"> & Overrides

function NavigationMenuItem({ xstyle, className, ...props }: NavigationMenuItemProps) {
  const sx = stylex.props(styles.item, xstyle)

  return (
    <NavigationMenuPrimitive.Item
      data-slot="navigation-menu-item"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type NavigationMenuTriggerProps = Omit<
  NavigationMenuPrimitive.Trigger.Props,
  "className" | "style"
> &
  StateOverrides

function NavigationMenuTrigger({
  xstyle,
  style,
  className,
  children,
  ...props
}: NavigationMenuTriggerProps) {
  return (
    <NavigationMenuPrimitive.Trigger
      data-slot="navigation-menu-trigger"
      {...props}
      {...stateStyles(
        (state) => [
          styles.trigger,
          states.focusRing,
          styles.surface,
          state.open && styles.current,
          xstyle,
        ],
        { className, style, hook: "carbon-icons" },
      )}
    >
      {children}
      <NavigationMenuPrimitive.Icon
        data-slot="navigation-menu-trigger-icon"
        {...stateStyles((state) => [styles.chevron, state.open && styles.chevronOpen])}
      >
        <ChevronDown aria-hidden="true" />
      </NavigationMenuPrimitive.Icon>
    </NavigationMenuPrimitive.Trigger>
  )
}

type NavigationMenuContentProps = Omit<
  NavigationMenuPrimitive.Content.Props,
  "className" | "style"
> &
  StateOverrides

function NavigationMenuContent({ xstyle, style, className, ...props }: NavigationMenuContentProps) {
  return (
    <NavigationMenuContentContext value={true}>
      <NavigationMenuPrimitive.Content
        data-slot="navigation-menu-content"
        {...props}
        {...stateStyles(
          (state) => {
            const direction = state.activationDirection
            const entering = state.transitionStatus === "starting"
            const leaving = state.transitionStatus === "ending"

            return [
              styles.content,
              (entering || leaving) && styles.hidden,
              direction && entering && slide[direction],
              direction && leaving && slide[opposite[direction]],
              xstyle,
            ]
          },
          { className, style },
        )}
      />
    </NavigationMenuContentContext>
  )
}

type NavigationMenuLinkProps = Omit<NavigationMenuPrimitive.Link.Props, "className" | "style"> &
  StateOverrides

function NavigationMenuLink({ xstyle, style, className, ...props }: NavigationMenuLinkProps) {
  const inContent = use(NavigationMenuContentContext)

  return (
    <NavigationMenuPrimitive.Link
      data-slot="navigation-menu-link"
      {...props}
      {...stateStyles(
        (state) => [
          styles.link,
          states.focusRing,
          styles.surface,
          inContent && states.popupItem,
          state.active && styles.current,
          xstyle,
        ],
        { className, style, hook: "carbon-icons" },
      )}
    />
  )
}

const navigationMenuStyles = {
  trigger: [styles.trigger, states.focusRing, styles.surface],
  link: [styles.link, states.focusRing, styles.surface],
}

export {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuPositioner,
  NavigationMenuTrigger,
  navigationMenuStyles,
}

Update the import paths to match your project setup.

Usage

import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
} from "@/components/ui/navigation-menu"
<NavigationMenu>
  <NavigationMenuList>
    <NavigationMenuItem>
      <NavigationMenuTrigger>Item One</NavigationMenuTrigger>
      <NavigationMenuContent>
        <NavigationMenuLink href="/one">Link</NavigationMenuLink>
      </NavigationMenuContent>
    </NavigationMenuItem>
  </NavigationMenuList>
</NavigationMenu>

Composition

NavigationMenu                      nav + portal, positioner, popup, viewport
└── NavigationMenuList
    ├── NavigationMenuItem
    │   ├── NavigationMenuTrigger
    │   └── NavigationMenuContent
    │       ├── NavigationMenuLink
    │       └── NavigationMenuLink
    └── NavigationMenuItem
        └── NavigationMenuLink

There is one popup for the whole menu. NavigationMenu renders it, and every NavigationMenuContent is shown inside it in turn, so the panel resizes and slides between triggers instead of closing and reopening.

There is no NavigationMenuIndicator. shadcn's is a Radix arrow rendered through Base UI's Icon part, which is the trigger's chevron here.

Use the render prop to hand navigation to a router link. href alone renders a plain <a>.

import { Link } from "@tanstack/react-router"

import {
  NavigationMenuItem,
  NavigationMenuLink,
  navigationMenuStyles,
} from "@/components/ui/navigation-menu"

export function DocsLink() {
  return (
    <NavigationMenuItem>
      <NavigationMenuLink render={<Link to="/docs" />} xstyle={navigationMenuStyles.trigger}>
        Documentation
      </NavigationMenuLink>
    </NavigationMenuItem>
  )
}

navigationMenuStyles.trigger gives a top-level link the trigger's height and padding so it lines up with its neighbours. Pass active to mark the current page. The link then takes the open trigger's fill and aria-current="page".

Large menus

The popup grows to fit the panel it shows. Cap it with popupProps when a panel can be taller than the viewport, and cap the panel to match.

const styles = stylex.create({
  capped: { maxHeight: "var(--available-height)" },
  scrolls: { maxHeight: "var(--available-height)", overflowY: "auto" },
})

<NavigationMenu popupProps={{ xstyle: styles.capped }}>
  <NavigationMenuList>
    <NavigationMenuItem>
      <NavigationMenuTrigger>Products</NavigationMenuTrigger>
      <NavigationMenuContent xstyle={styles.scrolls}>{/* … */}</NavigationMenuContent>
    </NavigationMenuItem>
  </NavigationMenuList>
</NavigationMenu>

A native scrollbar stays visible while the panel slides. If that is a problem, put a Scroll Area inside the content instead.

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI NavigationMenu part of the same name and accepts its props. See the Base UI reference for the full list. Each part also takes xstyle and className. NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink and the popup reached through popupProps take style too. It is real inline CSS, merged over whatever their Base UI state resolves to, not a StyleX override.

Renders a <nav> and, after its children, the portal, positioner, popup and viewport. The positioning props are forwarded to the positioner.

PropTypeDefault
valueValue | null—
defaultValueValue | nullnull
onValueChange(value: Value | null, details) => void—
delaynumber50
closeDelaynumber50
orientation"horizontal" | "vertical""horizontal"
side"top" | "bottom" | "left" | "right""bottom", "right" when vertical
sideOffsetnumber8
align"start" | "center" | "end""start"
alignOffsetnumber0
popupPropsNavigationMenuPrimitive.Popup.Props & { xstyle, className, style }—
PropTypeDefault
activebooleanfalse
closeOnClickbooleantrue
PropTypeDefault
keepMountedbooleanfalse
KeyDescription
triggerThe trigger's box and the shared focus ring, for a link that sits beside them
linkThe link's box and the same ring, for a control in a panel that is not a link

Each key pairs the part's own box with states.focusRing from theme/states.ts, so an element that uses one shows the same focus ring as the part it came from. The popup's fill and hairline ring are states.popupSurface and the positioner's layer is states.positioner. Neither is in the map, because neither is a box you would put on an element of your own.

Data attributes

AttributeOnEffect
data-slot="navigation-menu"the nav
data-orientationthe nav, listMirrors orientation
data-slot="navigation-menu-list"the ul
data-slot="navigation-menu-item"each li
data-slot="navigation-menu-trigger"a trigger
data-popup-open, data-presseda triggerBase UI writes them
data-slot="navigation-menu-trigger-icon"the chevronRotates while its item is open
data-slot="navigation-menu-content"a panel
data-activation-directiona panelWhich side the previous trigger was on; sets the slide
data-slot="navigation-menu-link"a link
data-activea linkMirrors active
data-slot="navigation-menu-positioner"the positionerSlides between triggers
data-side, data-align, data-instantthe positionerBase UI writes them
data-slot="navigation-menu-popup"the popupThe surface: fill, ring, radius
data-slot="navigation-menu-viewport"the viewportClips the panel while it slides
data-starting-style, data-ending-stylepopup and panelBase UI writes them while either animates