{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morphing-nav","type":"registry:component","title":"Morphing nav","description":"One dropdown that follows you along the menu, gliding and resizing to each item’s content.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/morphing-nav.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * MORPHING NAV: one dropdown that follows you along the menu\n *\n *   hover    a soft highlight slides under the item you point at\n *   open     a single panel drops in under the item, sized to its\n *            content\n *   move     going to another item, the same panel glides over\n *            and resizes to the new content while the content\n *            slides in from the side you moved toward\n *   close    leave the menu (or press Escape) and it fades away\n *\n * Items with content open the panel; items with only an href\n * are plain links. Arrow keys move between items; Enter or Down\n * opens one and moves into it.\n * ───────────────────────────────────────────────────────── */\n\nexport interface NavItem {\n  id: string;\n  label: string;\n  /** A plain link. */\n  href?: string;\n  /** What the panel shows for this item; give it its own width. */\n  content?: React.ReactNode;\n}\n\nexport interface MorphingNavProps extends React.HTMLAttributes<HTMLElement> {\n  items: NavItem[];\n  /** Space to keep from the screen edges. */\n  collisionPadding?: number;\n}\n\ntype Shown = { id: string; dir: number; key: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst MORPH = 380;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** A panel's content, sliding in from one side and out to the other. */\nfunction Slide({ children, dir, leaving, reduced, measureRef }: { children: React.ReactNode; dir: number; leaving: boolean; reduced: boolean; measureRef?: React.Ref<HTMLDivElement> }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el || reduced || dir === 0) return;\n    const shift = 28 * dir;\n    const animation = leaving\n      ? el.animate([{ opacity: 1, transform: \"none\" }, { opacity: 0, transform: `translateX(${-shift}px)` }], { duration: MORPH * 0.7, easing: EASE, fill: \"forwards\" })\n      : el.animate([{ opacity: 0, transform: `translateX(${shift}px)` }, { opacity: 1, transform: \"none\" }], { duration: MORPH, easing: EASE });\n    return () => animation.cancel();\n  }, [dir, leaving, reduced]);\n  return (\n    <div\n      ref={(el) => {\n        ref.current = el;\n        if (typeof measureRef === \"function\") measureRef(el);\n        else if (measureRef) (measureRef as React.MutableRefObject<HTMLDivElement | null>).current = el;\n      }}\n      aria-hidden={leaving || undefined}\n      inert={leaving}\n      className={`absolute left-0 top-0 w-max max-w-[calc(100vw-32px)] ${leaving ? \"pointer-events-none\" : \"\"}`}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport function MorphingNav({ items, collisionPadding = 16, className = \"\", ...props }: MorphingNavProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const triggerRefs = React.useRef(new Map<string, HTMLElement>());\n  const contentRef = React.useRef<HTMLDivElement | null>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const closeTimer = React.useRef(0);\n  const keyCounter = React.useRef(0);\n  const viaKeyboard = React.useRef(false);\n\n  const [open, setOpen] = React.useState<string | null>(null);\n  const [shown, setShown] = React.useState<Shown | null>(null);\n  const [leaving, setLeaving] = React.useState<Shown[]>([]);\n  const [visible, setVisible] = React.useState(false);\n  const [box, setBox] = React.useState({ left: 0, width: 0, height: 0, ready: false });\n  const [hover, setHover] = React.useState<{ left: number; width: number } | null>(null);\n\n  const index = (itemId: string | null) => items.findIndex((i) => i.id === itemId);\n\n  const show = (itemId: string) => {\n    window.clearTimeout(closeTimer.current);\n    const item = items.find((i) => i.id === itemId);\n    if (!item?.content) return;\n    if (open === itemId) return;\n    const dir = open ? Math.sign(index(itemId) - index(open)) : 0;\n    if (shown && open) setLeaving((l) => [...l, { ...shown, dir }]);\n    setShown({ id: itemId, dir, key: ++keyCounter.current });\n    setOpen(itemId);\n    setVisible(true);\n  };\n\n  const close = React.useCallback((delay = 0) => {\n    window.clearTimeout(closeTimer.current);\n    closeTimer.current = window.setTimeout(() => {\n      setOpen(null);\n      setVisible(false);\n      setHover(null);\n    }, delay);\n  }, []);\n\n  React.useEffect(() => () => window.clearTimeout(closeTimer.current), []);\n\n  // Once it has faded, forget the panel so the next open starts fresh.\n  React.useEffect(() => {\n    if (visible) return;\n    const timer = window.setTimeout(\n      () => {\n        setShown(null);\n        setLeaving([]);\n        setBox((b) => ({ ...b, ready: false }));\n      },\n      reduced ? 0 : 220\n    );\n    return () => window.clearTimeout(timer);\n  }, [visible, reduced]);\n\n  React.useEffect(() => {\n    if (!leaving.length) return;\n    const timer = window.setTimeout(() => setLeaving([]), MORPH);\n    return () => window.clearTimeout(timer);\n  }, [leaving]);\n\n  // Size and place the panel for the content now showing: under its item, inside the screen.\n  React.useLayoutEffect(() => {\n    const content = contentRef.current;\n    const root = rootRef.current;\n    const trigger = open ? triggerRefs.current.get(open) : null;\n    if (!content || !root || !trigger) return;\n    const measure = () => {\n      const r = root.getBoundingClientRect();\n      const t = trigger.getBoundingClientRect();\n      const width = content.offsetWidth;\n      const height = content.offsetHeight;\n      const viewport = document.documentElement.clientWidth;\n      const center = t.left + t.width / 2;\n      const left = Math.min(Math.max(center - width / 2, collisionPadding), viewport - collisionPadding - width) - r.left;\n      setBox((b) => (b.left === left && b.width === width && b.height === height && b.ready ? b : { left, width, height, ready: b.width > 0 }));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, [open, shown?.key, collisionPadding]);\n\n  // Opened from the keyboard: move focus into the panel.\n  React.useEffect(() => {\n    if (!open || !viaKeyboard.current) return;\n    viaKeyboard.current = false;\n    const first = panelRef.current?.querySelector<HTMLElement>(\"a[href], button:not([disabled]), [tabindex]:not([tabindex='-1'])\");\n    first?.focus();\n  }, [open, shown?.key]);\n\n  // Escape closes and hands focus back; clicking elsewhere closes.\n  React.useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      triggerRefs.current.get(open)?.focus();\n      close();\n    };\n    const onDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) close();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onDown);\n    };\n  }, [open, close]);\n\n  const point = (itemId: string) => {\n    const el = triggerRefs.current.get(itemId);\n    if (el) setHover({ left: el.offsetLeft, width: el.offsetWidth });\n  };\n\n  const onTriggerKey = (e: React.KeyboardEvent, item: NavItem) => {\n    const i = index(item.id);\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      const next = items[(i + (e.key === \"ArrowRight\" ? 1 : -1) + items.length) % items.length];\n      triggerRefs.current.get(next.id)?.focus();\n      if (open && next.content) show(next.id);\n    } else if (item.content && (e.key === \"ArrowDown\" || e.key === \"Enter\" || e.key === \" \")) {\n      e.preventDefault();\n      viaKeyboard.current = true;\n      if (open === item.id) {\n        const first = panelRef.current?.querySelector<HTMLElement>(\"a[href], button:not([disabled])\");\n        first?.focus();\n      } else show(item.id);\n    }\n  };\n\n  const content = shown ? items.find((i) => i.id === shown.id)?.content : null;\n  const size = reduced || !box.ready ? \"none\" : `left ${MORPH}ms ${EASE}, width ${MORPH}ms ${EASE}, height ${MORPH}ms ${EASE}`;\n\n  return (\n    <nav\n      ref={rootRef}\n      aria-label=\"Main\"\n      className={`relative ${className}`}\n      onPointerEnter={() => window.clearTimeout(closeTimer.current)}\n      onPointerLeave={(e) => e.pointerType === \"mouse\" && close(140)}\n      onBlur={(e) => {\n        if (!rootRef.current?.contains(e.relatedTarget as Node)) close();\n      }}\n      {...props}\n    >\n      <ul className=\"relative flex items-center gap-0.5\">\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-y-0 left-0 rounded-full bg-accent\"\n          style={{\n            width: hover?.width ?? 0,\n            transform: `translateX(${hover?.left ?? 0}px)`,\n            opacity: hover ? 1 : 0,\n            transition: reduced ? \"none\" : `transform 300ms ${EASE}, width 300ms ${EASE}, opacity 200ms ease-out`,\n          }}\n        />\n        {items.map((item) => {\n          const isOpen = open === item.id;\n          const common = {\n            ref: (el: HTMLElement | null) => {\n              if (el) triggerRefs.current.set(item.id, el);\n              else triggerRefs.current.delete(item.id);\n            },\n            onPointerEnter: (e: React.PointerEvent) => {\n              point(item.id);\n              if (e.pointerType === \"mouse\") {\n                if (item.content) show(item.id);\n                else if (open) close(140);\n              }\n            },\n            onFocus: () => point(item.id),\n            className: `relative flex h-9 items-center gap-1 rounded-full px-3.5 text-[13.5px] transition-colors duration-200 ${FOCUS} ${\n              isOpen ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"\n            }`,\n          };\n          return (\n            <li key={item.id}>\n              {item.content ? (\n                <button\n                  {...common}\n                  ref={common.ref}\n                  type=\"button\"\n                  aria-expanded={isOpen}\n                  aria-controls={isOpen ? `${id}-panel` : undefined}\n                  onClick={() => (isOpen ? close() : show(item.id))}\n                  onKeyDown={(e) => onTriggerKey(e, item)}\n                >\n                  {item.label}\n                  <svg\n                    aria-hidden=\"true\"\n                    viewBox=\"0 0 12 12\"\n                    className=\"size-3 opacity-60\"\n                    style={{ transform: isOpen ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 300ms ${EASE}` }}\n                  >\n                    <path d=\"M3 4.5 6 7.5 9 4.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                  </svg>\n                </button>\n              ) : (\n                <a {...common} ref={common.ref} href={item.href} onKeyDown={(e) => onTriggerKey(e, item)}>\n                  {item.label}\n                </a>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n\n      {shown && (\n        <div\n          ref={panelRef}\n          id={`${id}-panel`}\n          className=\"absolute top-full z-50 mt-2 overflow-hidden rounded-[20px] border border-border bg-popover text-popover-foreground animate-[ui-pop-in_240ms_cubic-bezier(0.16,1,0.3,1)_backwards] motion-reduce:animate-none\"\n          style={{\n            left: box.left,\n            width: box.width + 2,\n            height: box.height + 2,\n            opacity: visible ? 1 : 0,\n            transform: visible ? \"none\" : \"translateY(-4px) scale(0.98)\",\n            transformOrigin: \"top center\",\n            transition: reduced ? \"none\" : `${size === \"none\" ? \"\" : `${size}, `}opacity 200ms ease-out, transform 260ms ${EASE}`,\n          }}\n        >\n          {leaving.map((l) => (\n            <Slide key={l.key} dir={l.dir} leaving reduced={reduced}>\n              {items.find((i) => i.id === l.id)?.content}\n            </Slide>\n          ))}\n          <Slide key={shown.key} dir={shown.dir} leaving={false} reduced={reduced} measureRef={contentRef}>\n            {content}\n          </Slide>\n        </div>\n      )}\n    </nav>\n  );\n}\n"}],"css":{"@keyframes ui-pop-in":{"from":{"opacity":"0","transform":"translateY(-4px) scale(0.96)"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/morphing-nav"}