{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morphing-dropdown",
  "type": "registry:ui",
  "title": "Morphing Dropdown",
  "description": "A top-nav dropdown where one floating panel morphs its size and position between menus while content slides.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/morphing-dropdown.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useTransform } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface MorphingDropdownItem {\n  id: string;\n  label: string;\n  /** Panel content for this trigger. Its intrinsic size drives the panel's size. */\n  content: React.ReactNode;\n}\n\nexport interface MorphingDropdownProps extends Omit<React.HTMLAttributes<HTMLElement>, \"defaultValue\"> {\n  items: MorphingDropdownItem[];\n  /** Open menus when a mouse hovers a trigger (touch and keyboard always use click / Enter). */\n  openOnHover?: boolean;\n  /** Hover-intent delay before the first menu opens, in ms. */\n  openDelay?: number;\n  /** Grace period after the pointer leaves before the panel closes, in ms. */\n  closeDelay?: number;\n  /** Distance content slides when switching between triggers, in px. */\n  slideDistance?: number;\n  /** Show the arrow pointer above the panel. */\n  showArrow?: boolean;\n  /** Spring for the panel's size, position and arrow. */\n  spring?: Spring;\n  /** Accent for focus rings and the open trigger marker. */\n  accent?: string;\n  /** Accessible name of the nav landmark. */\n  label?: string;\n  /** Id of the menu open on first render (uncontrolled). */\n  defaultValue?: string | null;\n  /** Called when the open menu changes (null when closed). */\n  onValueChange?: (id: string | null) => void;\n}\n\nconst FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex=\"-1\"])';\nconst EDGE = 8; // px kept between the panel and the viewport edge\n\n/**\n * A top-navigation dropdown with ONE floating panel that morphs its size and position between\n * triggers while the content slides in the direction of travel. Disclosure semantics: each trigger\n * is a button with aria-expanded / aria-controls. Hover intent for mice, click for touch, full\n * keyboard support (Enter / ArrowDown opens, arrows move between triggers, Escape returns focus).\n */\nexport function MorphingDropdown({\n  items,\n  openOnHover = true,\n  openDelay = 120,\n  closeDelay = 240,\n  slideDistance = 48,\n  showArrow = true,\n  spring = { stiffness: 420, damping: 38, mass: 0.9 },\n  accent = \"#FF7448\",\n  label = \"Main\",\n  defaultValue = null,\n  onValueChange,\n  className = \"\",\n  style,\n  onPointerEnter,\n  onPointerLeave,\n  onKeyDown,\n  onBlur,\n  ...props\n}: MorphingDropdownProps) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const triggerRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const contentRefs = React.useRef(new Map<string, HTMLDivElement>());\n  const timers = React.useRef({ open: 0, close: 0 });\n  const hoverOpenedAt = React.useRef(0);\n  const focusContentRef = React.useRef(false);\n  const wasOpenRef = React.useRef(false);\n\n  const [active, setActive] = React.useState<string | null>(defaultValue);\n  const [direction, setDirection] = React.useState(0);\n\n  const width = useMotionValue(0);\n  const height = useMotionValue(0);\n  const x = useMotionValue(0);\n  const arrowX = useMotionValue(0);\n  const arrowLeft = useTransform(arrowX, (v) => v - 7);\n\n  const indexOf = (id: string | null) => items.findIndex((item) => item.id === id);\n  const contentId = (id: string) => `${uid}-content-${id}`;\n\n  const clearTimers = () => {\n    window.clearTimeout(timers.current.open);\n    window.clearTimeout(timers.current.close);\n  };\n\n  const open = (id: string) => {\n    clearTimers();\n    if (id === active) return;\n    setDirection(active === null ? 0 : Math.sign(indexOf(id) - indexOf(active)));\n    setActive(id);\n    onValueChange?.(id);\n  };\n\n  const close = (returnFocus = false) => {\n    clearTimers();\n    if (active === null) return;\n    if (returnFocus) triggerRefs.current.get(active)?.focus();\n    setActive(null);\n    onValueChange?.(null);\n  };\n\n  // Clean up timers on unmount.\n  React.useEffect(() => {\n    const t = timers.current;\n    return () => {\n      window.clearTimeout(t.open);\n      window.clearTimeout(t.close);\n    };\n  }, []);\n\n  /* ------------------------- measure and morph the panel ------------------------- */\n\n  const morph = React.useEffectEvent((id: string, instant: boolean) => {\n    const root = rootRef.current;\n    const content = contentRefs.current.get(id);\n    const trigger = triggerRefs.current.get(id);\n    if (!root || !content || !trigger) return;\n    const rootRect = root.getBoundingClientRect();\n    // Compensate for CSS transforms on ancestors (e.g. a zoomed preview stage).\n    const ratio = root.offsetWidth ? rootRect.width / root.offsetWidth || 1 : 1;\n    const t = trigger.getBoundingClientRect();\n    const w = content.offsetWidth;\n    const h = content.offsetHeight;\n    const center = (t.left + t.width / 2 - rootRect.left) / ratio;\n    const min = (EDGE - rootRect.left) / ratio;\n    const max = (document.documentElement.clientWidth - EDGE - rootRect.left) / ratio - w;\n    const left = Math.max(min, Math.min(center - w / 2, max));\n    const targets: [typeof width, number][] = [\n      [width, w],\n      [height, h],\n      [x, left],\n      [arrowX, Math.max(14, Math.min(center - left, w - 14))],\n    ];\n    for (const [mv, value] of targets) {\n      if (instant || reduceMotion) mv.set(value);\n      else animate(mv, value, { type: \"spring\", ...spring });\n    }\n  });\n\n  React.useEffect(() => {\n    if (active === null) {\n      wasOpenRef.current = false;\n      return;\n    }\n    const content = contentRefs.current.get(active);\n    if (!content) return;\n    morph(active, !wasOpenRef.current);\n    wasOpenRef.current = true;\n\n    if (focusContentRef.current) {\n      focusContentRef.current = false;\n      content.querySelector<HTMLElement>(FOCUSABLE)?.focus();\n    }\n\n    const ro = new ResizeObserver(() => morph(active, false));\n    ro.observe(content);\n    const onResize = () => morph(active, true);\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      ro.disconnect();\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [active]);\n\n  // Close on a pointer press outside the navigation.\n  React.useEffect(() => {\n    if (active === null) return;\n    const onPointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) {\n        clearTimeout(timers.current.open);\n        setActive(null);\n        onValueChange?.(null);\n      }\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [active, onValueChange]);\n\n  /* ---------------------------------- handlers ---------------------------------- */\n\n  const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, id: string) => {\n    const index = indexOf(id);\n    const last = items.length - 1;\n    const moveTo = (nextIndex: number) => {\n      const next = items[nextIndex];\n      triggerRefs.current.get(next.id)?.focus();\n      if (active !== null) open(next.id);\n    };\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusContentRef.current = true;\n        if (active === id) {\n          focusContentRef.current = false;\n          contentRefs.current.get(id)?.querySelector<HTMLElement>(FOCUSABLE)?.focus();\n        } else open(id);\n        break;\n      case \"ArrowRight\":\n        event.preventDefault();\n        moveTo(index === last ? 0 : index + 1);\n        break;\n      case \"ArrowLeft\":\n        event.preventDefault();\n        moveTo(index === 0 ? last : index - 1);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        moveTo(0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        moveTo(last);\n        break;\n      case \"Tab\":\n        // The shared panel sits after all triggers in the DOM: Tab from an open trigger goes into it.\n        if (!event.shiftKey && active === id) {\n          const first = contentRefs.current.get(id)?.querySelector<HTMLElement>(FOCUSABLE);\n          if (first) {\n            event.preventDefault();\n            first.focus();\n          }\n        }\n        break;\n    }\n  };\n\n  const handlePanelKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.key !== \"Tab\" || active === null) return;\n    const content = contentRefs.current.get(active);\n    const focusables = content ? Array.from(content.querySelectorAll<HTMLElement>(FOCUSABLE)) : [];\n    if (!focusables.length) return;\n    const index = focusables.indexOf(document.activeElement as HTMLElement);\n    if (event.shiftKey && index === 0) {\n      // Back to the trigger that owns this content.\n      event.preventDefault();\n      triggerRefs.current.get(active)?.focus();\n    } else if (!event.shiftKey && index === focusables.length - 1) {\n      // Continue with the trigger after the owner, or leave the navigation.\n      const next = items[indexOf(active) + 1];\n      if (next) {\n        event.preventDefault();\n        triggerRefs.current.get(next.id)?.focus();\n        close();\n      }\n    }\n  };\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const slide = reduceMotion ? 0 : slideDistance;\n  // AnimatePresence keeps the last rendered panel during its exit, so only the open item is needed.\n  const shownItem = items.find((item) => item.id === active);\n\n  return (\n    <nav\n      ref={rootRef}\n      aria-label={label}\n      className={`relative ${className}`}\n      style={{ [\"--md-accent\" as string]: accent, ...style }}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        if (event.pointerType === \"mouse\") window.clearTimeout(timers.current.close);\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        if (event.pointerType !== \"mouse\" || !openOnHover) return;\n        window.clearTimeout(timers.current.open);\n        if (active !== null) timers.current.close = window.setTimeout(() => close(), closeDelay);\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (event.key === \"Escape\" && active !== null) {\n          event.preventDefault();\n          close(true);\n        }\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        // Focus left the navigation entirely.\n        const next = event.relatedTarget as Node | null;\n        if (next && !event.currentTarget.contains(next)) close();\n      }}\n      {...props}\n    >\n      <ul className=\"flex items-center gap-1\">\n        {items.map((item) => {\n          const expanded = active === item.id;\n          return (\n            <li key={item.id}>\n              <button\n                ref={(node) => {\n                  if (node) triggerRefs.current.set(item.id, node);\n                  else triggerRefs.current.delete(item.id);\n                }}\n                type=\"button\"\n                aria-expanded={expanded}\n                aria-controls={expanded ? contentId(item.id) : undefined}\n                onPointerEnter={(event) => {\n                  if (event.pointerType !== \"mouse\" || !openOnHover) return;\n                  window.clearTimeout(timers.current.close);\n                  window.clearTimeout(timers.current.open);\n                  if (active !== null) open(item.id);\n                  else\n                    timers.current.open = window.setTimeout(() => {\n                      hoverOpenedAt.current = performance.now();\n                      open(item.id);\n                    }, openDelay);\n                }}\n                onPointerLeave={() => window.clearTimeout(timers.current.open)}\n                onClick={() => {\n                  if (expanded) {\n                    // A click right after hover-intent opened this menu should not immediately close it.\n                    if (performance.now() - hoverOpenedAt.current > 400) close();\n                  } else open(item.id);\n                }}\n                onKeyDown={(event) => handleTriggerKeyDown(event, item.id)}\n                className={`group inline-flex h-9 items-center gap-1.5 rounded-full px-3.5 text-sm font-medium tracking-tight transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--md-accent)] ${\n                  expanded\n                    ? \"bg-zinc-900/[0.06] text-zinc-950 dark:bg-white/[0.08] dark:text-white\"\n                    : \"text-zinc-600 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white\"\n                }`}\n              >\n                {item.label}\n                <svg\n                  viewBox=\"0 0 12 12\"\n                  aria-hidden=\"true\"\n                  fill=\"none\"\n                  className={`h-3 w-3 opacity-60 transition-transform duration-200 ${expanded ? \"rotate-180\" : \"\"}`}\n                >\n                  <path d=\"M3 4.5 6 7.5l3-3\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                </svg>\n              </button>\n            </li>\n          );\n        })}\n      </ul>\n\n      <AnimatePresence>\n        {active !== null && shownItem && (\n          <motion.div\n            key=\"panel\"\n            initial={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.98 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.98, transition: { duration: 0.16 } }}\n            transition={reduceMotion ? { duration: 0.12 } : { duration: 0.22, ease: [0.16, 1, 0.3, 1] }}\n            onKeyDown={handlePanelKeyDown}\n            // The invisible ::before bridges the gap between the triggers and the panel for the pointer.\n            className=\"absolute left-0 top-full z-50 mt-3 origin-top before:absolute before:inset-x-0 before:-top-3 before:h-3\"\n            style={{ x, width, height }}\n          >\n            {showArrow && (\n              <motion.span\n                aria-hidden=\"true\"\n                className=\"absolute -top-[7px] z-10 h-3.5 w-3.5 rotate-45 rounded-tl-[3px] border-l border-t border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900\"\n                style={{ left: arrowLeft }}\n              />\n            )}\n            <div className=\"absolute inset-0 overflow-hidden rounded-2xl border border-zinc-900/10 bg-white text-zinc-900 shadow-[0_24px_60px_-24px_rgba(0,0,0,0.3),0_2px_6px_rgba(0,0,0,0.05)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-[0_24px_60px_-20px_rgba(0,0,0,0.9)]\">\n              <AnimatePresence initial={false} custom={direction}>\n                <motion.div\n                  key={shownItem.id}\n                  id={contentId(shownItem.id)}\n                  ref={(node: HTMLDivElement | null) => {\n                    if (node) contentRefs.current.set(shownItem.id, node);\n                    else contentRefs.current.delete(shownItem.id);\n                  }}\n                  custom={direction}\n                  variants={{\n                    enter: (d: number) => ({ opacity: 0, x: d * slide }),\n                    center: { opacity: 1, x: 0 },\n                    exit: (d: number) => ({ opacity: 0, x: d * -slide }),\n                  }}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={reduceMotion ? { duration: 0.12 } : { ...transition, opacity: { duration: 0.18 } }}\n                  className=\"absolute left-0 top-0 w-max max-w-[calc(100vw-16px)]\"\n                >\n                  {shownItem.content}\n                </motion.div>\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </nav>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/morphing-dropdown"
}
