# Navigation menu

> Site navigation with dropdowns, or one full-width panel that stays open while its content slides between menus.

Docs: https://hextaui.com/docs/navigation-menu
Markdown: https://hextaui.com/docs/navigation-menu.md

```tsx title="components/examples/navigation-menu/demo.tsx"
import {
  IconBook,
  IconBrandGithub,
  IconBuildingStore,
  IconChartBar,
  IconCode,
  IconComponents,
  IconLayoutDashboard,
  IconLifebuoy,
  IconPalette,
  IconRocket,
  IconShieldLock,
  IconUsers,
} from "@tabler/icons-react"

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

type Entry = {
  title: string
  description: string
  icon: typeof IconBook
}

const products: Entry[] = [
  {
    title: "Components",
    description: "Accessible building blocks with motion built in.",
    icon: IconComponents,
  },
  {
    title: "Blocks",
    description: "Full sections you can drop into a page.",
    icon: IconLayoutDashboard,
  },
  {
    title: "Themes",
    description: "Tokens for color, radius and type.",
    icon: IconPalette,
  },
  {
    title: "Templates",
    description: "Starter apps ready to deploy.",
    icon: IconBuildingStore,
  },
]

const solutions: Entry[] = [
  {
    title: "Startups",
    description: "Ship a polished product on day one.",
    icon: IconRocket,
  },
  {
    title: "Teams",
    description: "One system shared across every app.",
    icon: IconUsers,
  },
  {
    title: "Analytics",
    description: "Dashboards, tables and charts.",
    icon: IconChartBar,
  },
  {
    title: "Security",
    description: "Audited for accessibility and safe defaults.",
    icon: IconShieldLock,
  },
  {
    title: "Developers",
    description: "Typed APIs that read like shadcn.",
    icon: IconCode,
  },
  {
    title: "Open source",
    description: "Read every line on GitHub.",
    icon: IconBrandGithub,
  },
]

const resources: Entry[] = [
  {
    title: "Documentation",
    description: "Every component, example and prop.",
    icon: IconBook,
  },
  {
    title: "Support",
    description: "Questions, bugs and feature requests.",
    icon: IconLifebuoy,
  },
]

function Links({ entries }: { entries: Entry[] }) {
  return (
    <ul className="grid gap-1 sm:grid-cols-2">
      {entries.map((entry) => (
        <li key={entry.title}>
          <NavigationMenuLink href="#">
            <span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-background ring-(length:--hairline) ring-border">
              <entry.icon className="text-foreground" />
            </span>
            <span className="flex min-w-0 flex-col">
              <span className="font-medium">{entry.title}</span>
              <span className="text-muted-foreground">{entry.description}</span>
            </span>
          </NavigationMenuLink>
        </li>
      ))}
    </ul>
  )
}

export function NavigationMenuDemo() {
  return (
    <div className="w-full max-w-2xl rounded-xl border px-2 py-1.5">
      <NavigationMenu variant="panel" aria-label="Main">
        <NavigationMenuList>
          <NavigationMenuItem>
            <NavigationMenuTrigger>Products</NavigationMenuTrigger>
            <NavigationMenuContent>
              <Links entries={products} />
            </NavigationMenuContent>
          </NavigationMenuItem>
          <NavigationMenuItem>
            <NavigationMenuTrigger>Solutions</NavigationMenuTrigger>
            <NavigationMenuContent>
              <Links entries={solutions} />
            </NavigationMenuContent>
          </NavigationMenuItem>
          <NavigationMenuItem>
            <NavigationMenuTrigger>Resources</NavigationMenuTrigger>
            <NavigationMenuContent>
              <Links entries={resources} />
            </NavigationMenuContent>
          </NavigationMenuItem>
          <NavigationMenuItem>
            <NavigationMenuLink href="#">Pricing</NavigationMenuLink>
          </NavigationMenuItem>
        </NavigationMenuList>
      </NavigationMenu>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/navigation-menu.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/navigation-menu.tsx"
"use client"

import * as React from "react"
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
import { IconChevronDown } from "@tabler/icons-react"
import { cva } from "class-variance-authority"
import { cn } from "cn"

import { SafeAreaOverlay } from "@/components/ui/safe-area"

import { useSlidingHighlight } from "@/lib/motion"

type NavigationMenuVariant = "dropdown" | "panel"

const NavigationMenuContext =
  React.createContext<NavigationMenuVariant>("dropdown")

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

type NavigationMenuProps = NavigationMenuPrimitive.Root.Props &
  Pick<
    NavigationMenuPrimitive.Positioner.Props,
    "align" | "sideOffset" | "alignOffset" | "collisionPadding"
  > & {
    variant?: NavigationMenuVariant
    showSafeArea?: boolean
  }

function NavigationMenu({
  variant = "dropdown",
  align,
  sideOffset,
  alignOffset,
  collisionPadding,
  className,
  children,
  value: valueProp,
  defaultValue,
  onValueChange,
  showSafeArea = false,
  ref,
  ...props
}: NavigationMenuProps) {
  const rootRef = React.useRef<HTMLElement | null>(null)
  const popupRef = React.useRef<HTMLDivElement | null>(null)
  const [open, setOpen] = React.useState(
    defaultValue != null && valueProp === undefined
  )
  const getElements = React.useCallback(() => {
    const reference = rootRef.current?.querySelector(
      "[data-slot=navigation-menu-trigger][data-popup-open]"
    )
    const floating = popupRef.current
    return reference && floating ? { reference, floating } : null
  }, [])

  const highlightRef = React.useRef<HTMLSpanElement | null>(null)
  useSlidingHighlight(
    rootRef,
    highlightRef,
    "[data-slot=navigation-menu-trigger][data-popup-open]"
  )

  const setRef = React.useCallback(
    (node: HTMLElement | null) => {
      rootRef.current = node
      if (typeof ref === "function") {
        return ref(node as never)
      }
      if (ref) {
        ;(ref as React.RefObject<HTMLElement | null>).current = node
      }
      return undefined
    },
    [ref]
  )

  return (
    <NavigationMenuContext.Provider value={variant}>
      <NavigationMenuPrimitive.Root
        ref={setRef}
        data-slot="navigation-menu"
        data-variant={variant}
        value={valueProp}
        defaultValue={defaultValue}
        onValueChange={(next, details) => {
          onValueChange?.(next, details)
          if (!details.isCanceled) {
            setOpen(next != null)
          }
        }}
        className={mergeClassName(
          cn(
            "group/navigation-menu relative isolate flex min-w-0 flex-1 items-center",
            variant === "panel"
              ? "w-full max-w-none"
              : "max-w-max justify-center"
          ),
          className
        )}
        {...props}
      >
        <span
          ref={highlightRef}
          aria-hidden="true"
          data-slot="navigation-menu-highlight"
          className="pointer-events-none absolute top-0 -z-1 rounded-md bg-accent opacity-0 transition-[transform,width,height,opacity] duration-300 ease-out-quint data-instant:transition-opacity data-visible:opacity-100 motion-reduce:transition-opacity"
        />
        {children}
        <NavigationMenuPositioner
          align={align ?? (variant === "panel" ? "center" : "start")}
          sideOffset={sideOffset}
          alignOffset={alignOffset}
          collisionPadding={collisionPadding}
          anchor={variant === "panel" ? rootRef : undefined}
          popupRef={popupRef}
        />
        {showSafeArea &&
        (valueProp === undefined ? open : valueProp != null) ? (
          <SafeAreaOverlay getElements={getElements} />
        ) : null}
      </NavigationMenuPrimitive.Root>
    </NavigationMenuContext.Provider>
  )
}

function NavigationMenuList({
  className,
  ...props
}: NavigationMenuPrimitive.List.Props) {
  return (
    <NavigationMenuPrimitive.List
      data-slot="navigation-menu-list"
      className={mergeClassName(
        "-my-1 flex min-w-0 flex-1 [scrollbar-width:none] list-none items-center gap-0.5 overflow-x-auto overscroll-x-none py-1 group-data-[orientation=vertical]/navigation-menu:flex-col group-data-[orientation=vertical]/navigation-menu:items-stretch",
        className
      )}
      {...props}
    />
  )
}

function NavigationMenuItem({
  className,
  ...props
}: NavigationMenuPrimitive.Item.Props) {
  return (
    <NavigationMenuPrimitive.Item
      data-slot="navigation-menu-item"
      className={mergeClassName("relative", className)}
      {...props}
    />
  )
}

const navigationMenuTriggerStyle = cva(
  "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center gap-1 rounded-md px-3 text-sm font-medium whitespace-nowrap outline-none select-none focus-visible:ring-3 focus-visible:inset-ring-(length:--hairline) focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden data-popup-open:text-accent-foreground data-[active]:text-foreground pointer-coarse:h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [@media(hover:hover)]:group-not-has-[[data-popup-open]]/navigation-menu:hover:bg-muted/70"
)

function NavigationMenuTrigger({
  className,
  children,
  ...props
}: NavigationMenuPrimitive.Trigger.Props) {
  return (
    <NavigationMenuPrimitive.Trigger
      data-slot="navigation-menu-trigger"
      className={mergeClassName(navigationMenuTriggerStyle(), className)}
      {...props}
    >
      {children}
      <IconChevronDown
        aria-hidden="true"
        data-slot="navigation-menu-trigger-icon"
        className="size-3.5 text-muted-foreground transition-transform duration-300 ease-out-quint group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:text-accent-foreground motion-reduce:transition-none"
      />
    </NavigationMenuPrimitive.Trigger>
  )
}

const contentSlide =
  "transition-[opacity,translate,filter] duration-300 ease-out-quint data-ending-style:opacity-0 data-starting-style:opacity-0 motion-safe:data-ending-style:blur-[2px] motion-safe:data-starting-style:blur-[2px] motion-safe:data-starting-style:data-[activation-direction=left]:-translate-x-12 motion-safe:data-starting-style:data-[activation-direction=right]:translate-x-12 motion-safe:data-ending-style:data-[activation-direction=left]:translate-x-12 motion-safe:data-ending-style:data-[activation-direction=right]:-translate-x-12 motion-safe:data-starting-style:data-[activation-direction=up]:-translate-y-6 motion-safe:data-starting-style:data-[activation-direction=down]:translate-y-6 motion-safe:data-ending-style:data-[activation-direction=up]:translate-y-6 motion-safe:data-ending-style:data-[activation-direction=down]:-translate-y-6 motion-reduce:transition-opacity"

const linkSelector = "[data-slot=navigation-menu-link]"

const arrowKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"])

function nearestLink(from: HTMLElement, links: HTMLElement[], key: string) {
  const origin = from.getBoundingClientRect()
  const ox = origin.left + origin.width / 2
  const oy = origin.top + origin.height / 2
  const vertical = key === "ArrowDown" || key === "ArrowUp"
  let best: HTMLElement | null = null
  let bestScore = Infinity
  for (const link of links) {
    if (link === from) {
      continue
    }
    const rect = link.getBoundingClientRect()
    const dx = rect.left + rect.width / 2 - ox
    const dy = rect.top + rect.height / 2 - oy
    const ahead =
      key === "ArrowDown"
        ? dy > 1
        : key === "ArrowUp"
          ? dy < -1
          : key === "ArrowRight"
            ? dx > 1
            : dx < -1
    if (!ahead) {
      continue
    }
    const score = vertical
      ? Math.abs(dy) + Math.abs(dx) * 2
      : Math.abs(dx) + Math.abs(dy) * 2
    if (score < bestScore) {
      bestScore = score
      best = link
    }
  }
  return best
}

function NavigationMenuContent({
  className,
  children,
  onPointerOver,
  onPointerLeave,
  onFocus,
  onKeyDown,
  ref,
  ...props
}: NavigationMenuPrimitive.Content.Props) {
  const variant = React.useContext(NavigationMenuContext)
  const contentRef = React.useRef<HTMLDivElement | null>(null)
  const highlightRef = React.useRef<HTMLSpanElement | null>(null)
  const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(
    null
  )
  const mountedRef = React.useMemo(
    () => ({ current: contentNode }),
    [contentNode]
  )
  useSlidingHighlight(
    mountedRef,
    highlightRef,
    "[data-slot=navigation-menu-link][data-highlighted]",
    "data-highlighted"
  )

  const setRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      contentRef.current = node
      setContentNode(node)
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  const highlight = (link: Element | null) => {
    const content = contentRef.current
    if (!content) {
      return
    }
    for (const current of content.querySelectorAll("[data-highlighted]")) {
      if (current !== link) {
        current.removeAttribute("data-highlighted")
      }
    }
    link?.setAttribute("data-highlighted", "")
  }

  const focused = () =>
    contentRef.current?.querySelector(`${linkSelector}:focus-visible`) ?? null

  return (
    <NavigationMenuPrimitive.Content
      ref={setRef}
      data-slot="navigation-menu-content"
      data-variant={variant}
      className={mergeClassName(
        cn(
          "relative isolate",
          "h-full p-2 [--navigation-menu-link-radius:max(calc(var(--radius-sm)*0.5),calc(var(--radius-xl)-var(--spacing)*2))]",
          variant === "panel" ? "w-(--anchor-width) p-3" : "w-max",
          contentSlide
        ),
        className
      )}
      onPointerOver={(event) => {
        onPointerOver?.(event)
        if (event.pointerType !== "touch") {
          highlight(
            (event.target as Element).closest(linkSelector) ?? focused()
          )
        }
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event)
        highlight(focused())
      }}
      onFocus={(event) => {
        onFocus?.(event)
        const target = event.target as Element
        if (target.matches(linkSelector) && target.matches(":focus-visible")) {
          highlight(target)
        }
      }}
      onKeyDown={(event) => {
        onKeyDown?.(event)
        const content = contentRef.current
        const target = event.target as HTMLElement
        if (
          event.defaultPrevented ||
          !content ||
          !arrowKeys.has(event.key) ||
          !target.matches(linkSelector)
        ) {
          return
        }
        const links = Array.from(
          content.querySelectorAll<HTMLElement>(linkSelector)
        ).filter((link) => link.getClientRects().length > 0)
        const next = nearestLink(target, links, event.key)
        if (next) {
          event.preventDefault()
          event.preventBaseUIHandler?.()
          next.focus()
          highlight(next)
        }
      }}
      {...props}
    >
      <span
        ref={highlightRef}
        aria-hidden="true"
        data-slot="navigation-menu-link-highlight"
        className="pointer-events-none absolute top-0 -z-1 rounded-(--navigation-menu-link-radius) bg-muted opacity-0 transition-[transform,width,height,opacity] duration-200 ease-out-quint data-instant:transition-opacity data-visible:opacity-100 motion-reduce:transition-opacity"
      />
      {children}
    </NavigationMenuPrimitive.Content>
  )
}

function NavigationMenuPositioner({
  className,
  side = "bottom",
  sideOffset = 8,
  align = "start",
  alignOffset = 0,
  collisionPadding = 8,
  anchor,
  popupRef,
  ...props
}: NavigationMenuPrimitive.Positioner.Props & {
  popupRef?: React.Ref<HTMLDivElement>
}) {
  const variant = React.useContext(NavigationMenuContext)

  return (
    <NavigationMenuPrimitive.Portal>
      <NavigationMenuPrimitive.Positioner
        data-slot="navigation-menu-positioner"
        side={side}
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
        collisionPadding={collisionPadding}
        anchor={anchor}
        className={mergeClassName(
          cn(
            "isolate z-50 h-(--positioner-height) max-w-(--available-width) transition-[top,left,right,bottom] duration-300 ease-out-quint data-instant:transition-none motion-reduce:transition-none",
            variant === "panel"
              ? "w-(--anchor-width)"
              : "w-(--positioner-width)"
          ),
          className
        )}
        {...props}
      >
        <NavigationMenuPrimitive.Popup
          ref={popupRef}
          data-slot="navigation-menu-popup"
          className={cn(
            "relative h-(--popup-height) max-h-(--available-height) origin-(--transform-origin) overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-md ring-(length:--hairline) ring-foreground/10 transition-[opacity,scale,width,height] duration-300 ease-out-quint outline-none focus-visible:outline-hidden data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:opacity-0 motion-safe:data-ending-style:scale-97 motion-safe:data-starting-style:scale-97 motion-reduce:transition-opacity forced-colors:border",
            variant === "panel" ? "w-full" : "w-(--popup-width)"
          )}
        >
          <NavigationMenuPrimitive.Viewport
            data-slot="navigation-menu-viewport"
            className="relative size-full overflow-x-hidden overflow-y-auto overscroll-contain"
          />
        </NavigationMenuPrimitive.Popup>
      </NavigationMenuPrimitive.Positioner>
    </NavigationMenuPrimitive.Portal>
  )
}

function NavigationMenuLink({
  className,
  ...props
}: NavigationMenuPrimitive.Link.Props) {
  return (
    <NavigationMenuPrimitive.Link
      data-slot="navigation-menu-link"
      className={mergeClassName(
        "flex min-w-0 items-center gap-2 rounded-(--navigation-menu-link-radius,var(--radius-md)) p-2 text-sm transition-colors duration-150 outline-none not-in-data-[slot=navigation-menu-content]:h-9 not-in-data-[slot=navigation-menu-content]:px-3 not-in-data-[slot=navigation-menu-content]:py-0 not-in-data-[slot=navigation-menu-content]:font-medium focus-visible:ring-3 focus-visible:inset-ring-(length:--hairline) focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden data-[active]:bg-muted/60 motion-reduce:transition-none pointer-coarse:not-in-data-[slot=navigation-menu-content]:h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [@media(hover:hover)]:not-in-data-[slot=navigation-menu-content]:hover:bg-muted",
        className
      )}
      {...props}
    />
  )
}

export {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuPositioner,
  NavigationMenuTrigger,
  navigationMenuTriggerStyle,
}
export type { NavigationMenuProps, NavigationMenuVariant }
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

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

```tsx
<NavigationMenu variant="panel" aria-label="Main">
  <NavigationMenuList>
    <NavigationMenuItem>
      <NavigationMenuTrigger>Products</NavigationMenuTrigger>
      <NavigationMenuContent>
        <NavigationMenuLink href="/components">Components</NavigationMenuLink>
      </NavigationMenuContent>
    </NavigationMenuItem>
    <NavigationMenuItem>
      <NavigationMenuLink href="/pricing">Pricing</NavigationMenuLink>
    </NavigationMenuItem>
  </NavigationMenuList>
</NavigationMenu>
```

With `variant="panel"`, one card spans the whole bar. Moving between triggers keeps it open and in place: only the content slides toward the direction you moved, and the height eases to fit. Nothing closes, reopens or jumps.

## Composition

```text
NavigationMenu
└── NavigationMenuList
    ├── NavigationMenuItem
    │   ├── NavigationMenuTrigger
    │   └── NavigationMenuContent
    │       └── NavigationMenuLink
    └── NavigationMenuItem
        └── NavigationMenuLink
```

## Examples

### Dropdown

The default `dropdown` variant opens a card under each trigger. Moving to the next trigger glides the same card over and resizes it to the new content.

```tsx title="components/examples/navigation-menu/dropdown.tsx"
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
} from "@/components/ui/navigation-menu"

const guides = [
  { title: "Installation", description: "Add HextaUI to a Next.js app." },
  { title: "Theming", description: "Colors, radius and dark mode." },
  { title: "Motion", description: "Durations, easing and reduced motion." },
]

const components = [
  "Accordion",
  "Button",
  "Dialog",
  "Menubar",
  "Table",
  "Tooltip",
]

export function NavigationMenuDropdown() {
  return (
    <NavigationMenu aria-label="Docs">
      <NavigationMenuList>
        <NavigationMenuItem>
          <NavigationMenuTrigger>Guides</NavigationMenuTrigger>
          <NavigationMenuContent>
            <ul className="flex w-72 flex-col gap-1">
              {guides.map((guide) => (
                <li key={guide.title}>
                  <NavigationMenuLink href="#">
                    <span className="flex flex-col">
                      <span className="font-medium">{guide.title}</span>
                      <span className="text-muted-foreground">
                        {guide.description}
                      </span>
                    </span>
                  </NavigationMenuLink>
                </li>
              ))}
            </ul>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem>
          <NavigationMenuTrigger>Components</NavigationMenuTrigger>
          <NavigationMenuContent>
            <ul className="grid w-80 grid-cols-2 gap-1">
              {components.map((name) => (
                <li key={name}>
                  <NavigationMenuLink href="#">{name}</NavigationMenuLink>
                </li>
              ))}
            </ul>
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem>
          <NavigationMenuLink href="#">Blog</NavigationMenuLink>
        </NavigationMenuItem>
      </NavigationMenuList>
    </NavigationMenu>
  )
}
```

### Current page

Links in the bar look like triggers. Mark the current page with `active`, which also sets `aria-current="page"`.

```tsx title="components/examples/navigation-menu/active.tsx"
import {
  NavigationMenu,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
} from "@/components/ui/navigation-menu"

const links = ["Overview", "Activity", "Settings"]

export function NavigationMenuActive() {
  return (
    <NavigationMenu aria-label="Project">
      <NavigationMenuList>
        {links.map((link) => (
          <NavigationMenuItem key={link}>
            <NavigationMenuLink href="#" active={link === "Activity"}>
              {link}
            </NavigationMenuLink>
          </NavigationMenuItem>
        ))}
      </NavigationMenuList>
    </NavigationMenu>
  )
}
```

### Right to left

Content slides follow the direction you move, which flips in right-to-left layouts.

```tsx title="components/examples/navigation-menu/rtl.tsx"
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuTrigger,
} from "@/components/ui/navigation-menu"

const menus = [
  { label: "المنتجات", links: ["المكونات", "القوالب", "السمات"] },
  { label: "الموارد", links: ["التوثيق", "الدعم"] },
]

export function NavigationMenuRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md rounded-xl border px-2 py-1.5">
      <NavigationMenu variant="panel" aria-label="الرئيسية">
        <NavigationMenuList>
          {menus.map((menu) => (
            <NavigationMenuItem key={menu.label}>
              <NavigationMenuTrigger>{menu.label}</NavigationMenuTrigger>
              <NavigationMenuContent>
                <ul className="grid grid-cols-2 gap-1">
                  {menu.links.map((link) => (
                    <li key={link}>
                      <NavigationMenuLink href="#">{link}</NavigationMenuLink>
                    </li>
                  ))}
                </ul>
              </NavigationMenuContent>
            </NavigationMenuItem>
          ))}
        </NavigationMenuList>
      </NavigationMenu>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves through the bar, and from an open trigger into its content. |
| `Enter` `Space` | Opens or closes the focused trigger's menu. |
| `↑` `↓` | Moves between links in the open content. |
| `←` `→` | Moves between triggers in the bar. |
| `Esc` | Closes the menu and returns focus to its trigger. |

## Accessibility

- The root renders a `<nav>`. Give it an `aria-label` when the page has more than one.
- Triggers are buttons that report whether their menu is open, and focus returns to them when the menu closes.
- The content slide and the sliding highlight turn into fades when reduced motion is on.

## API reference

### NavigationMenu

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"dropdown" \| "panel"` | `"dropdown"` | dropdown opens under each trigger. panel opens one full-width card under the bar that stays put while content slides. |
| `showSafeArea` | `boolean` | `false` | Draws the safe area that keeps a menu open while the pointer heads into it. For debugging and demos. |
| `align` | `"start" \| "center" \| "end"` | `"start", or "center" for panel` |  |
| `sideOffset` | `number` | `8` |  |
| `value` | `any` | – |  |
| `defaultValue` | `any` | – |  |
| `onValueChange` | `(value, details) => void` | – |  |
| `delay` | `number` | `50` | Wait before opening on hover, in ms. |
| `closeDelay` | `number` | `50` | Wait before closing after the pointer leaves, in ms. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="navigation-menu"` | The nav, with data-variant. |
| `data-slot="navigation-menu-highlight"` | The background that slides between open triggers. |
| `data-slot="navigation-menu-popup"` | The card. Its height eases through --popup-height. |

### NavigationMenuTrigger

| Attribute | Description |
| --- | --- |
| `data-slot="navigation-menu-trigger"` | Target triggers in CSS. |
| `data-popup-open` | Present while its menu is open. The chevron flips. |

### NavigationMenuContent

| Attribute | Description |
| --- | --- |
| `data-slot="navigation-menu-content"` | A menu's content, with data-variant. |
| `data-activation-direction` | "left", "right", "up" or "down": where the previous trigger was. Drives the slide. |
| `--navigation-menu-link-radius` | Link corner radius, concentric with the card. |

### NavigationMenuLink

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `active` | `boolean` | `false` | Marks the current page. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<a>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="navigation-menu-link"` | Target links in CSS. In the bar they size themselves like triggers. |
| `data-active` | Present on the current page. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
