{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-nav",
  "title": "Morph Nav",
  "description": "A floating action button that morphs through states with SVG path-morphing trigger icon.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/morph-nav.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n  type RefObject,\n} from 'react'\nimport { createPortal } from 'react-dom'\nimport { AnimatePresence, motion, useReducedMotion } from 'motion/react'\nimport { cn } from '@/lib/utils'\nimport { springs } from '@/lib/motion-tokens'\nimport { useProximityHighlight, ProximityHighlight } from '@/lib/hooks/use-proximity-highlight'\n\nexport interface MorphNavItem {\n  id: string\n  label: string\n  icon?: ReactNode\n  href?: string\n  onSelect?: () => void\n  children?: MorphNavItem[]\n}\n\ntype MorphNavState = 'closed' | 'main' | 'sub'\n\ninterface MorphNavContextValue {\n  state: MorphNavState\n  setState: (s: MorphNavState) => void\n  items: MorphNavItem[]\n  activeSubId: string | null\n  setActiveSubId: (id: string | null) => void\n  onNavigate?: (href: string, item: MorphNavItem) => void\n}\n\nconst MorphNavContext = createContext<MorphNavContextValue | null>(null)\n\nfunction useMorphNavCtx(componentName: string) {\n  const ctx = useContext(MorphNavContext)\n  if (!ctx) throw new Error(`${componentName} must be used within MorphNav`)\n  return ctx\n}\n\nconst PATH_HAMBURGER = 'M3 5h10M3 8h10M3 11h10'\nconst PATH_MINUS = 'M3 8h10'\nconst PATH_BACK = 'M7 5L3 8l4 3'\n\nfunction getTriggerPaths(state: MorphNavState) {\n  if (state === 'closed') return { top: PATH_HAMBURGER, mid: PATH_HAMBURGER, bot: PATH_HAMBURGER }\n  if (state === 'main') return { top: PATH_MINUS, mid: PATH_MINUS, bot: PATH_MINUS }\n  return { top: PATH_BACK, mid: PATH_BACK, bot: PATH_BACK }\n}\n\nexport interface MorphNavProps {\n  children?: ReactNode\n  items: MorphNavItem[]\n  placement?: 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start'\n  onNavigate?: (href: string, item: MorphNavItem) => void\n  onOpenChange?: (open: boolean) => void\n  triggerLabel?: string\n  className?: string\n  panelClassName?: string\n}\n\nexport const MorphNav = forwardRef<HTMLDivElement, MorphNavProps>(\n  (\n    {\n      items,\n      placement = 'bottom-end',\n      onNavigate,\n      onOpenChange,\n      triggerLabel = 'Menu',\n      className,\n      panelClassName,\n    },\n    ref,\n  ) => {\n    const [state, setState] = useState<MorphNavState>('closed')\n    const [activeSubId, setActiveSubId] = useState<string | null>(null)\n    const triggerRef = useRef<HTMLButtonElement>(null)\n    const panelRef = useRef<HTMLDivElement>(null)\n    const reduceMotion = useReducedMotion()\n\n    const reactId = useId()\n    const panelId = `${reactId}-panel`\n\n    const handleSetState = useCallback(\n      (next: MorphNavState) => {\n        setState(next)\n        onOpenChange?.(next !== 'closed')\n        if (next === 'closed') {\n          setActiveSubId(null)\n          requestAnimationFrame(() => triggerRef.current?.focus())\n        }\n      },\n      [onOpenChange],\n    )\n\n    useEffect(() => {\n      if (state === 'closed') return\n      const handlePointerDown = (e: PointerEvent) => {\n        const target = e.target as Node\n        if (panelRef.current?.contains(target)) return\n        if (triggerRef.current?.contains(target)) return\n        handleSetState('closed')\n      }\n      document.addEventListener('pointerdown', handlePointerDown)\n      return () => document.removeEventListener('pointerdown', handlePointerDown)\n    }, [state, handleSetState])\n\n    useEffect(() => {\n      if (state === 'closed') return\n      const handleKeyDown = (e: KeyboardEvent) => {\n        if (e.key === 'Escape') {\n          e.preventDefault()\n          if (state === 'sub') {\n            handleSetState('main')\n          } else {\n            handleSetState('closed')\n          }\n        }\n      }\n      document.addEventListener('keydown', handleKeyDown)\n      return () => document.removeEventListener('keydown', handleKeyDown)\n    }, [state, handleSetState])\n\n    const ctx = useMemo<MorphNavContextValue>(\n      () => ({ state, setState: handleSetState, items, activeSubId, setActiveSubId, onNavigate }),\n      [state, handleSetState, items, activeSubId, onNavigate],\n    )\n\n    const paths = getTriggerPaths(state)\n    const triggerAriaLabel =\n      state === 'closed' ? triggerLabel : state === 'sub' ? 'Back' : `Close ${triggerLabel}`\n\n    return (\n      <MorphNavContext.Provider value={ctx}>\n        <div ref={ref} className={cn('relative inline-block', className)}>\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            aria-expanded={state !== 'closed'}\n            aria-haspopup=\"menu\"\n            aria-label={triggerAriaLabel}\n            className={cn(\n              'flex size-11 items-center justify-center rounded-full squircle-corners bg-(--color-fg) text-(--color-bg) transition-colors duration-(--motion-dur-fast) motion-reduce:transition-none',\n              'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-bg)',\n            )}\n            onClick={() => handleSetState(state === 'closed' ? 'main' : 'closed')}\n          >\n            <svg\n              width=\"16\"\n              height=\"16\"\n              viewBox=\"0 0 14 16\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.5\"\n              strokeLinecap=\"round\"\n              aria-hidden=\"true\"\n            >\n              <motion.path\n                d={paths.top}\n                initial={false}\n                animate={{ d: paths.top }}\n                transition={reduceMotion ? { duration: 0 } : springs.settle}\n              />\n              <motion.path\n                d={paths.mid}\n                initial={false}\n                animate={{ d: paths.mid }}\n                transition={reduceMotion ? { duration: 0 } : springs.settle}\n              />\n              <motion.path\n                d={paths.bot}\n                initial={false}\n                animate={{ d: paths.bot }}\n                transition={reduceMotion ? { duration: 0 } : springs.settle}\n              />\n            </svg>\n          </button>\n\n          <AnimatePresence>\n            {state !== 'closed' && (\n              <MorphNavPanel\n                ref={panelRef}\n                id={panelId}\n                placement={placement}\n                className={panelClassName}\n                triggerRef={triggerRef}\n              />\n            )}\n          </AnimatePresence>\n        </div>\n      </MorphNavContext.Provider>\n    )\n  },\n)\nMorphNav.displayName = 'MorphNav'\n\ninterface MorphNavPanelProps {\n  id: string\n  placement: 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start'\n  className?: string\n  triggerRef: RefObject<HTMLButtonElement | null>\n}\n\nconst MorphNavPanel = forwardRef<HTMLDivElement, MorphNavPanelProps>(\n  ({ id, placement, className, triggerRef }, ref) => {\n    const { state, setState, items, activeSubId } = useMorphNavCtx('MorphNavPanel')\n    const panelRef = useRef<HTMLDivElement>(null)\n    const reduceMotion = useReducedMotion()\n    const [position, setPosition] = useState({ left: 0, top: 0 })\n\n    const { registerItem, handlers, highlightX, highlightSize, highlightOpacity, axis } =\n      useProximityHighlight(panelRef, { axis: 'y' })\n\n    const displayItems = useMemo(() => {\n      if (state === 'sub' && activeSubId) {\n        const parent = items.find((item) => item.id === activeSubId)\n        return parent?.children ?? []\n      }\n      return items\n    }, [state, items, activeSubId])\n\n    useEffect(() => {\n      const trigger = triggerRef.current\n      const panel = panelRef.current\n      if (!trigger || !panel) return\n      const triggerRect = trigger.getBoundingClientRect()\n      const panelRect = panel.getBoundingClientRect()\n      const vw = window.innerWidth\n      const vh = window.innerHeight\n      const margin = 8\n\n      let left: number\n      let top: number\n\n      if (placement.includes('end')) {\n        left = triggerRect.right - panelRect.width\n      } else {\n        left = triggerRect.left\n      }\n      left = Math.max(margin, Math.min(left, vw - panelRect.width - margin))\n\n      if (placement.includes('top')) {\n        top = triggerRect.top - panelRect.height - 8\n      } else {\n        top = triggerRect.bottom + 8\n      }\n      top = Math.max(margin, Math.min(top, vh - panelRect.height - margin))\n\n      setPosition({ left, top })\n    }, [state, placement, displayItems, triggerRef])\n\n    useEffect(() => {\n      if (state === 'closed') return\n      const panel = panelRef.current\n      if (!panel) return\n\n      const firstItem = panel.querySelector<HTMLElement>('[role=\"menuitem\"]:not([data-disabled])')\n      firstItem?.focus()\n\n      const handleKeyDown = (e: KeyboardEvent) => {\n        if (!panel.contains(document.activeElement)) return\n        const menuItems = panel.querySelectorAll<HTMLElement>(\n          '[role=\"menuitem\"]:not([data-disabled])',\n        )\n        if (!menuItems.length) return\n        const count = menuItems.length\n        const currentIdx = Array.from(menuItems).indexOf(document.activeElement as HTMLElement)\n\n        if (e.key === 'ArrowDown') {\n          e.preventDefault()\n          const next = currentIdx < 0 ? 0 : (currentIdx + 1) % count\n          menuItems[next]?.focus()\n        } else if (e.key === 'ArrowUp') {\n          e.preventDefault()\n          const next = currentIdx < 0 ? count - 1 : (currentIdx - 1 + count) % count\n          menuItems[next]?.focus()\n        } else if (e.key === 'Home') {\n          e.preventDefault()\n          menuItems[0]?.focus()\n        } else if (e.key === 'End') {\n          e.preventDefault()\n          menuItems[count - 1]?.focus()\n        } else if (e.key === 'Tab') {\n          setState('closed')\n        }\n      }\n      document.addEventListener('keydown', handleKeyDown)\n      return () => document.removeEventListener('keydown', handleKeyDown)\n    }, [state, setState])\n\n    const indexRef = useRef(0)\n    const nextIndex = useCallback(() => {\n      const idx = indexRef.current\n      indexRef.current += 1\n      return idx\n    }, [])\n\n    const setRefs = useCallback(\n      (el: HTMLDivElement | null) => {\n        ;(panelRef as React.MutableRefObject<HTMLDivElement | null>).current = el\n        if (typeof ref === 'function') ref(el)\n        else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el\n      },\n      [ref],\n    )\n\n    return createPortal(\n      <motion.div\n        ref={setRefs}\n        id={id}\n        role=\"menu\"\n        className={cn(\n          'fixed z-300 min-w-[12rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-lg squircle-corners border border-(--color-border) bg-(--color-bg) p-1.5 outline-none',\n          'shadow-[0_14px_34px_-22px_rgba(0,0,0,0.15)]',\n          className,\n        )}\n        style={{ left: position.left, top: position.top }}\n        initial={reduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.95 }}\n        animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n        exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}\n        transition={\n          reduceMotion ? { duration: 0 } : { ...springs.settle, opacity: { duration: 0.12 } }\n        }\n        tabIndex={-1}\n        {...handlers}\n      >\n        <ProximityHighlight\n          highlightX={highlightX}\n          highlightSize={highlightSize}\n          highlightOpacity={highlightOpacity}\n          axis={axis}\n          className=\"mx-1.5 rounded-md squircle-corners bg-(--color-surface-2)\"\n        />\n        {displayItems.map((item) => (\n          <MorphNavItemComponent\n            key={item.id}\n            item={item}\n            nextIndex={nextIndex}\n            registerItem={registerItem}\n          />\n        ))}\n      </motion.div>,\n      document.body,\n    )\n  },\n)\nMorphNavPanel.displayName = 'MorphNavPanel'\n\nfunction MorphNavItemComponent({\n  item,\n  nextIndex,\n  registerItem,\n}: {\n  item: MorphNavItem\n  nextIndex: () => number\n  registerItem: (index: number, el: HTMLElement | null) => void\n}) {\n  const { setState, setActiveSubId, onNavigate } = useMorphNavCtx('MorphNavItem')\n  const itemRef = useRef<HTMLButtonElement | null>(null)\n  const idxRef = useRef<number | null>(null)\n  if (idxRef.current === null) idxRef.current = nextIndex()\n  // eslint-disable-next-line react-hooks/refs\n  const idx = idxRef.current\n\n  useEffect(() => {\n    const el = itemRef.current\n    if (idx < 0 || !el) return\n    registerItem(idx, el)\n    return () => registerItem(idx, null)\n  }, [idx, registerItem])\n\n  const hasChildren = item.children && item.children.length > 0\n\n  return (\n    <button\n      ref={itemRef}\n      type=\"button\"\n      role=\"menuitem\"\n      tabIndex={-1}\n      className={cn(\n        'relative flex min-h-11 w-full cursor-default select-none items-center gap-3 rounded-md squircle-corners px-3 py-2.5 text-left text-sm outline-none transition-colors duration-(--motion-dur-fast) motion-reduce:transition-none',\n        'text-(--color-fg)',\n      )}\n      onClick={() => {\n        if (hasChildren) {\n          setActiveSubId(item.id)\n          setState('sub')\n        } else {\n          if (item.href && onNavigate) {\n            onNavigate(item.href, item)\n          }\n          item.onSelect?.()\n          setState('closed')\n        }\n      }}\n    >\n      {item.icon && (\n        <span className=\"relative z-10 shrink-0 [&_svg]:size-4\" aria-hidden=\"true\">\n          {item.icon}\n        </span>\n      )}\n      <span className=\"relative z-10 flex min-w-0 flex-1 items-center truncate\">{item.label}</span>\n      {hasChildren && (\n        <span className=\"relative z-10 shrink-0 text-(--color-muted)\" aria-hidden=\"true\">\n          <svg\n            width=\"14\"\n            height=\"14\"\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <path d=\"M6 4l4 4-4 4\" />\n          </svg>\n        </span>\n      )}\n    </button>\n  )\n}\n\nexport function MorphNavPreview() {\n  return (\n    <div\n      className=\"flex h-full w-full items-center justify-center p-6\"\n      style={{ backgroundColor: 'var(--color-surface)' }}\n    >\n      <MorphNav\n        items={[\n          { id: 'dash', label: 'Dashboard', href: '/dash' },\n          {\n            id: 'auto',\n            label: 'Automations',\n            children: [{ id: 'wf', label: 'Workflows', href: '/wf' }],\n          },\n          { id: 'settings', label: 'Settings', href: '/settings' },\n        ]}\n        placement=\"bottom-end\"\n      />\n    </div>\n  )\n}\n"
    }
  ],
  "type": "registry:ui"
}
