{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"navigation-menu","type":"registry:ui","title":"Navigation menu","description":"A site header whose items open frosted panels of links. Hover waits for intent, the panel resizes from one item to the next, and every link is reachable by keyboard.","dependencies":["@radix-ui/react-navigation-menu","clsx","lucide-react","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/glide-highlight.tsx","type":"registry:ui","target":"components/ui/glide-highlight.tsx","content":"\"use client\";\n\nimport { useLayoutEffect, useState } from \"react\";\nimport \"./glide-highlight.css\";\n\nexport type GlideBox = {\n  active: boolean;\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n};\n\nexport function glideBox(target: HTMLElement): GlideBox {\n  return {\n    active: true,\n    left: target.offsetLeft,\n    top: target.offsetTop,\n    width: target.offsetWidth,\n    height: target.offsetHeight,\n  };\n}\n\nexport function useHighlightedGlide(\n  root: HTMLElement | null,\n  selector = \"[data-highlighted]\",\n  /** Attributes whose change can move the target, e.g. data-state for an open trigger. */\n  attributes: readonly string[] = [\"data-highlighted\"],\n) {\n  const [box, setBox] = useState<GlideBox | null>(null);\n\n  useLayoutEffect(() => {\n    const element = root;\n    if (!element) return;\n    const update = () => {\n      const target = element.querySelector<HTMLElement>(selector);\n      setBox((current) =>\n        target ? glideBox(target) : current ? { ...current, active: false } : null,\n      );\n    };\n    update();\n    const observer = new MutationObserver(update);\n    observer.observe(element, {\n      subtree: true,\n      attributes: true,\n      attributeFilter: [...attributes],\n    });\n    return () => observer.disconnect();\n    // The attribute list is fixed per call site.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [root, selector]);\n\n  return box;\n}\n\nexport function GlideHighlight({ box }: { box: GlideBox | null }) {\n  if (!box) return null;\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"glide-highlight\"\n      data-active={box.active}\n      style={{\n        width: box.width,\n        height: box.height,\n        transform: `translate(${box.left}px, ${box.top}px)`,\n      }}\n    />\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/navigation-menu.tsx","type":"registry:ui","target":"components/ui/navigation-menu.tsx","content":"\"use client\";\n\nimport * as Primitive from \"@radix-ui/react-navigation-menu\";\nimport { ArrowUpRight, ChevronDown } from \"lucide-react\";\nimport { useLayoutEffect, useState, type ComponentProps, type CSSProperties, type ReactNode } from \"react\";\nimport { GlideHighlight, glideBox, type GlideBox } from \"./glide-highlight\";\nimport { cn } from \"./lib/cn\";\nimport \"./navigation-menu.css\";\n\n/**\n * A site header's destinations, some with a panel of links. Hover waits for\n * intent before opening, a highlight glides between items, and the frosted\n * panel follows from one item to the next by resizing in place rather than\n * closing and opening again. Every panel is reachable by keyboard: Enter or\n * ↓ opens it, Tab walks its links, Escape closes it and returns to its item.\n * Below a tablet width use a drawer instead: a flyout is a pointer pattern.\n */\nexport function NavigationMenu({ className, children, ...props }: ComponentProps<typeof Primitive.Root>) {\n  const [root, setRoot] = useState<HTMLElement | null>(null);\n  // The panel starts under its item and glides to the next one, but never past the window edge.\n  useLayoutEffect(() => {\n    if (!root) return;\n    const anchor = root.querySelector<HTMLElement>('[data-slot=\"navigation-viewport-anchor\"]');\n    if (!anchor) return;\n    let wasOpen = false;\n    const place = () => {\n      const viewport = anchor.querySelector<HTMLElement>('[data-slot=\"navigation-viewport\"]');\n      const trigger = root.querySelector<HTMLElement>('[data-slot=\"navigation-item-control\"][data-state=\"open\"]');\n      if (!viewport || !trigger) {\n        wasOpen = false;\n        return;\n      }\n      const width = parseFloat(viewport.style.getPropertyValue(\"--radix-navigation-menu-viewport-width\")) || viewport.offsetWidth;\n      const box = root.getBoundingClientRect();\n      const wanted = trigger.getBoundingClientRect().left - box.left - 8;\n      const left = Math.max(16 - box.left, Math.min(wanted, window.innerWidth - 16 - width - box.left));\n      anchor.toggleAttribute(\"data-instant\", !wasOpen);\n      anchor.style.setProperty(\"--nav-offset\", `${Math.round(left)}px`);\n      wasOpen = true;\n    };\n    place();\n    const observer = new MutationObserver(place);\n    observer.observe(root, { subtree: true, attributes: true, attributeFilter: [\"data-state\", \"style\"] });\n    window.addEventListener(\"resize\", place);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"resize\", place);\n    };\n  }, [root]);\n  return (\n    <Primitive.Root ref={setRoot} data-slot=\"navigation-menu\" className={cn(\"relative z-20 flex max-w-full\", className)} {...props}>\n      {children}\n      <div data-slot=\"navigation-viewport-anchor\" className=\"absolute top-full left-0\">\n        <Primitive.Viewport data-slot=\"navigation-viewport\" />\n      </div>\n    </Primitive.Root>\n  );\n}\n\nexport function NavigationMenuList({ className, children, ...props }: ComponentProps<typeof Primitive.List>) {\n  const [list, setList] = useState<HTMLUListElement | null>(null);\n  const [box, setBox] = useState<GlideBox | null>(null);\n  // The highlight sits under the item you point at or focus, and rests on the open item otherwise.\n  useLayoutEffect(() => {\n    if (!list) return;\n    const update = () => {\n      const target =\n        list.querySelector<HTMLElement>('[data-slot=\"navigation-item-control\"][data-hovered]') ??\n        list.querySelector<HTMLElement>('[data-slot=\"navigation-item-control\"][data-state=\"open\"]');\n      setBox((current) => (target ? glideBox(target) : current ? { ...current, active: false } : null));\n    };\n    update();\n    const observer = new MutationObserver(update);\n    observer.observe(list, { subtree: true, attributes: true, attributeFilter: [\"data-state\", \"data-hovered\"] });\n    return () => observer.disconnect();\n  }, [list]);\n  return (\n    <Primitive.List\n      ref={setList}\n      data-slot=\"navigation-list\"\n      className={cn(\"relative flex items-center gap-0.5\", className)}\n      onPointerOver={(event) => markHover(list, event.target)}\n      onPointerLeave={() => markHover(list, null)}\n      onFocus={(event) => markHover(list, event.target)}\n      onBlur={() => markHover(list, null)}\n      {...props}\n    >\n      <GlideHighlight box={box} />\n      {children}\n    </Primitive.List>\n  );\n}\n\n/* Pointer and focus position, mirrored in an attribute the observer above can watch. */\nfunction markHover(list: HTMLElement | null, target: EventTarget | null) {\n  if (!list) return;\n  const control = target instanceof Element ? target.closest('[data-slot=\"navigation-item-control\"]') : null;\n  list.querySelectorAll(\"[data-hovered]\").forEach((element) => element !== control && element.removeAttribute(\"data-hovered\"));\n  if (control && list.contains(control)) control.setAttribute(\"data-hovered\", \"\");\n}\n\nexport const NavigationMenuItem = Primitive.Item;\n\nconst control =\n  \"relative z-10 inline-flex h-9 select-none items-center gap-1 rounded-control px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors duration-[var(--duration-fast)] ease-[var(--ease-out)] hover:text-foreground focus-visible:text-foreground focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[state=open]:text-foreground data-[active]:text-foreground\";\n\nexport function NavigationMenuTrigger({ className, children, ...props }: ComponentProps<typeof Primitive.Trigger>) {\n  return (\n    <Primitive.Trigger data-slot=\"navigation-item-control\" className={cn(control, \"cursor-default\", className)} {...props}>\n      {children}\n      <ChevronDown aria-hidden=\"true\" data-slot=\"navigation-chevron\" className=\"size-3.5\" />\n    </Primitive.Trigger>\n  );\n}\n\n/** A top-level destination without a panel. Pass `active` on the current page. */\nexport function NavigationMenuLink({ className, ...props }: ComponentProps<typeof Primitive.Link>) {\n  return <Primitive.Link data-slot=\"navigation-item-control\" className={cn(control, className)} {...props} />;\n}\n\nexport function NavigationMenuContent({ className, ...props }: ComponentProps<typeof Primitive.Content>) {\n  return <Primitive.Content data-slot=\"navigation-content\" className={cn(\"p-2\", className)} {...props} />;\n}\n\n/**\n * A link inside a panel: an optional icon, a title and one line saying what\n * is there. `external` adds an arrow and opens a new tab.\n */\nexport function NavigationMenuCard({\n  href,\n  title,\n  description,\n  icon,\n  external = false,\n  active,\n  className,\n}: {\n  href: string;\n  title: string;\n  description?: string;\n  icon?: ReactNode;\n  external?: boolean;\n  active?: boolean;\n  className?: string;\n}) {\n  return (\n    <Primitive.Link asChild active={active}>\n      <a\n        href={href}\n        data-slot=\"navigation-card\"\n        target={external ? \"_blank\" : undefined}\n        rel={external ? \"noreferrer\" : undefined}\n        aria-current={active ? \"page\" : undefined}\n        className={className}\n      >\n        {icon && <span data-slot=\"navigation-card-icon\" aria-hidden=\"true\">{icon}</span>}\n        <span className=\"grid min-w-0 gap-0.5\">\n          <span className=\"inline-flex items-center gap-1 text-[13px] font-medium text-foreground\">\n            {title}\n            {external && (\n              <>\n                <ArrowUpRight aria-hidden=\"true\" className=\"size-3.5 text-muted-foreground\" />\n                <span className=\"sr-only\">(opens in a new tab)</span>\n              </>\n            )}\n          </span>\n          {description && <span className=\"text-xs leading-[18px] text-muted-foreground\">{description}</span>}\n        </span>\n      </a>\n    </Primitive.Link>\n  );\n}\n\n/**\n * Lays a panel out in labelled columns, with an optional featured cell on\n * the end (a launch, a guide). Columns stack in a narrow viewport.\n */\nexport function NavigationMenuPanel({\n  columns,\n  featured,\n  className,\n}: {\n  columns: readonly { title?: string; links: readonly ComponentProps<typeof NavigationMenuCard>[] }[];\n  featured?: ReactNode;\n  className?: string;\n}) {\n  return (\n    <div data-slot=\"navigation-panel\" data-featured={featured ? \"\" : undefined} style={{ \"--nav-columns\": columns.length } as CSSProperties} className={className}>\n      {columns.map((column, index) => (\n        <div key={column.title ?? index} className=\"grid content-start gap-0.5\">\n          {column.title && <p data-slot=\"navigation-panel-title\">{column.title}</p>}\n          <ul className=\"grid gap-0.5\">\n            {column.links.map((link) => (\n              <li key={link.href + link.title}>\n                <NavigationMenuCard {...link} />\n              </li>\n            ))}\n          </ul>\n        </div>\n      ))}\n      {featured && <div data-slot=\"navigation-featured\">{featured}</div>}\n    </div>\n  );\n}\n"},{"path":"ui/glide-highlight.css","type":"registry:file","target":"components/ui/glide-highlight.css","content":"/* glide-highlight.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"glide-highlight\"] {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 0;\n    pointer-events: none;\n    border-radius: var(--radius-surface);\n    background: var(--muted);\n    opacity: 0;\n    transition:\n      transform var(--duration-travel) var(--ease-showcase),\n      width var(--duration-travel) var(--ease-showcase),\n      height var(--duration-travel) var(--ease-showcase),\n      opacity var(--duration-deliberate) var(--ease-out);\n  }\n  [data-slot=\"glide-highlight\"][data-active=\"true\"] {\n    opacity: 1;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"glide-highlight\"] {\n      transition: none;\n    }\n    [data-slot=\"glide-highlight\"] {\n      transition:\n        transform 400ms var(--ease-showcase),\n        width 400ms var(--ease-showcase),\n        height 400ms var(--ease-showcase),\n        opacity 280ms var(--ease-out);\n    }\n  }\n}\n"},{"path":"ui/navigation-menu.css","type":"registry:file","target":"components/ui/navigation-menu.css","content":"/* navigation-menu.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n[data-slot=\"navigation-viewport\"] {\n  background-color: var(--elevated-glass);\n  border-color: color-mix(in srgb, var(--edge-light) 45%, var(--border));\n  backdrop-filter: blur(var(--blur-overlay));\n  box-shadow:\n    var(--shadow-overlay),\n    0 0 40px var(--edge-glow),\n    inset 0 1px 0 var(--edge-light);\n}\n@layer components {\n  /* Navigation menu */\n  [data-slot=\"navigation-list\"] { list-style: none; margin: 0; padding: 0; }\n  [data-slot=\"navigation-list\"] > [data-slot=\"glide-highlight\"] { border-radius: var(--radius-control); }\n  [data-slot=\"navigation-chevron\"] { transition: rotate var(--duration-normal) var(--ease-settle); }\n  [data-state=\"open\"] > [data-slot=\"navigation-chevron\"] { rotate: -180deg; }\n  [data-slot=\"navigation-viewport-anchor\"] { translate: var(--nav-offset, 0) 0; transition: translate var(--duration-deliberate) var(--ease-out); }\n  [data-slot=\"navigation-viewport-anchor\"][data-instant] { transition: none; }\n  [data-slot=\"navigation-viewport\"] {\n    position: relative; margin-top: 8px; overflow: hidden;\n    width: var(--radix-navigation-menu-viewport-width); height: var(--radix-navigation-menu-viewport-height);\n    max-width: calc(100vw - 2rem);\n    border: 1px solid var(--border); border-radius: var(--radius-surface);\n    transform-origin: top left;\n    transition: width var(--duration-deliberate) var(--ease-out), height var(--duration-deliberate) var(--ease-out);\n  }\n  [data-slot=\"navigation-viewport\"][data-state=\"open\"] { animation: caliper-dialog-enter var(--duration-normal) var(--ease-out); }\n  [data-slot=\"navigation-viewport\"][data-state=\"closed\"] { animation: caliper-fade-out var(--duration-fast) var(--ease-in) forwards; }\n  [data-slot=\"navigation-content\"] { position: absolute; top: 0; left: 0; width: max-content; max-width: calc(100vw - 2rem); }\n  [data-slot=\"navigation-content\"][data-motion=\"from-start\"] { animation: caliper-nav-panel-from-start var(--duration-deliberate) var(--ease-out); }\n  [data-slot=\"navigation-content\"][data-motion=\"from-end\"] { animation: caliper-nav-panel-from-end var(--duration-deliberate) var(--ease-out); }\n  [data-slot=\"navigation-content\"][data-motion=\"to-start\"] { animation: caliper-nav-panel-to-start var(--duration-fast) var(--ease-in) forwards; }\n  [data-slot=\"navigation-content\"][data-motion=\"to-end\"] { animation: caliper-nav-panel-to-end var(--duration-fast) var(--ease-in) forwards; }\n  [data-slot=\"navigation-panel\"] { display: grid; gap: 4px 12px; width: min(calc(var(--nav-columns) * 260px), calc(100vw - 3rem)); grid-template-columns: repeat(var(--nav-columns), minmax(0, 1fr)); }\n  [data-slot=\"navigation-panel\"][data-featured] { width: min(calc(var(--nav-columns) * 260px + 240px), calc(100vw - 3rem)); grid-template-columns: repeat(var(--nav-columns), minmax(0, 1fr)) 228px; }\n  @media (max-width: 720px) { [data-slot=\"navigation-panel\"], [data-slot=\"navigation-panel\"][data-featured] { grid-template-columns: minmax(0, 1fr); width: calc(100vw - 3rem); } }\n  [data-slot=\"navigation-panel-title\"] { margin: 6px 10px 4px; font-size: 12px; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted-foreground); }\n  [data-slot=\"navigation-panel\"] ul { list-style: none; margin: 0; padding: 0; }\n  [data-slot=\"navigation-card\"] {\n    display: flex; align-items: flex-start; gap: 10px; padding: 9px 10px; border-radius: var(--radius-control); text-decoration: none; outline: none;\n    transition: background-color var(--duration-fast) var(--ease-out);\n  }\n  [data-slot=\"navigation-card\"]:hover, [data-slot=\"navigation-card\"]:focus-visible { background: var(--muted); }\n  [data-slot=\"navigation-card\"]:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }\n  [data-slot=\"navigation-card\"][aria-current=\"page\"] { background: var(--selected); }\n  [data-slot=\"navigation-card-icon\"] { display: grid; flex: none; place-items: center; width: 32px; height: 32px; border-radius: 8px; background: var(--raised); box-shadow: var(--shadow-raised); color: var(--foreground); }\n  [data-slot=\"navigation-card-icon\"] svg { width: 16px; height: 16px; }\n  [data-slot=\"navigation-featured\"] { display: grid; align-content: start; justify-items: start; gap: 6px; min-height: 100%; padding: 14px; border-radius: 12px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--border); }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"navigation-viewport\"], [data-slot=\"navigation-content\"] { animation: none !important; }\n  }\n  /* Wide rows (accordion, sidebar, donut legend) set their pressed fill with an active: utility next to their hover:\n     utility, since utilities outrank this layer. The navigation menu trigger has none, so it is set here. */\n  [data-slot=\"navigation-item-control\"]:active:not(:disabled):not([aria-disabled=\"true\"]) {\n    background-color: var(--raised-press);\n  }\n}\n@keyframes caliper-nav-panel-from-start { from { opacity: 0; translate: -16px 0; } to { opacity: 1; translate: 0 0; } }\n@keyframes caliper-nav-panel-from-end { from { opacity: 0; translate: 16px 0; } to { opacity: 1; translate: 0 0; } }\n@keyframes caliper-nav-panel-to-start { to { opacity: 0; translate: -16px 0; } }\n@keyframes caliper-nav-panel-to-end { to { opacity: 0; translate: 16px 0; } }\n"}],"categories":["navigation"],"meta":{"tier":"free","license":"MIT","family":"identity-navigation","docs":"/docs/components/navigation-menu"}}