{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "interactive-dock",
  "type": "registry:ui",
  "title": "Interactive Dock",
  "description": "A navigation dock whose tiles rise and grow as the pointer approaches.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/interactive-dock.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\n\n/* SSR-safe: renders \"no preference\" on the server and the first client pass, so markup always hydrates. */\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\nexport interface DockItem {\n  /** Accessible name, also shown in the tooltip. */\n  label: string;\n  /** Icon element (an <svg> scales with the tile). */\n  icon: React.ReactNode;\n  /** Renders the item as a link. */\n  href?: string;\n  /** Click handler (renders a button when there is no href). */\n  onClick?: (event: React.MouseEvent<HTMLElement>) => void;\n  /** Marks the current item with an indicator and aria-current. */\n  active?: boolean;\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface InteractiveDockProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  items: DockItem[];\n  /** Scale of the tile directly under the pointer (1 = no magnification). */\n  magnification?: number;\n  /** Distance in px from the pointer over which neighbours are magnified. */\n  distance?: number;\n  /** Resting tile size in px. */\n  size?: number;\n  /** Spring used to settle tile sizes. */\n  spring?: Spring;\n  /** Accent colour for the active indicator and focus ring. */\n  accent?: string;\n  /** Shape of the active indicator. */\n  indicator?: \"dot\" | \"bar\";\n  /** Accessible name of the navigation landmark. */\n  label?: string;\n}\n\n/* --------------------------- pointer capability --------------------------- */\n\nconst FINE_POINTER_QUERY = \"(hover: hover) and (pointer: fine)\";\n\nfunction subscribeFinePointer(onChange: () => void) {\n  const mq = window.matchMedia(FINE_POINTER_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction useFinePointer() {\n  return React.useSyncExternalStore(\n    subscribeFinePointer,\n    () => window.matchMedia(FINE_POINTER_QUERY).matches,\n    () => true,\n  );\n}\n\n/* ---------------------------------- tiles --------------------------------- */\n\nconst TILE =\n  \"relative flex h-full w-full items-center justify-center rounded-[30%] border border-zinc-900/10 bg-gradient-to-b from-white to-zinc-100 text-zinc-700 \" +\n  \"shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_1px_2px_rgba(0,0,0,0.06)] transition-colors hover:text-zinc-950 \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--dock-accent)] \" +\n  \"aria-[current]:text-zinc-950 dark:border-white/10 dark:from-zinc-800 dark:to-zinc-900 dark:text-zinc-300 \" +\n  \"dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:text-white dark:aria-[current]:text-white \" +\n  \"[&_svg]:h-[46%] [&_svg]:w-[46%]\";\n\nfunction DockLink({ item, className, ...rest }: { item: DockItem; className: string } & React.HTMLAttributes<HTMLElement>) {\n  const current = item.active ? (item.href ? \"page\" : \"true\") : undefined;\n  const content = (\n    <span aria-hidden=\"true\" className=\"pointer-events-none flex h-full w-full items-center justify-center\">\n      {item.icon}\n    </span>\n  );\n  return item.href ? (\n    <a data-dock-item=\"\" href={item.href} aria-label={item.label} aria-current={current} onClick={item.onClick} className={className} {...rest}>\n      {content}\n    </a>\n  ) : (\n    <button data-dock-item=\"\" type=\"button\" aria-label={item.label} aria-current={current} onClick={item.onClick} className={className} {...rest}>\n      {content}\n    </button>\n  );\n}\n\nfunction Indicator({ shape, visible, className }: { shape: \"dot\" | \"bar\"; visible: boolean; className: string }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={`pointer-events-none absolute left-1/2 -translate-x-1/2 rounded-full bg-[var(--dock-accent)] transition-[opacity,scale] duration-300 ${\n        shape === \"dot\" ? \"h-1 w-1\" : \"h-[3px] w-3.5\"\n      } ${visible ? \"scale-100 opacity-100\" : \"scale-0 opacity-0\"} ${className}`}\n    />\n  );\n}\n\ninterface MagnifiedTileProps {\n  item: DockItem;\n  index: number;\n  pointerX: MotionValue<number>;\n  size: number;\n  magnification: number;\n  distance: number;\n  spring: Spring;\n  indicator: \"dot\" | \"bar\";\n}\n\nfunction MagnifiedTile({ item, index, pointerX, size, magnification, distance, spring, indicator }: MagnifiedTileProps) {\n  const ref = React.useRef<HTMLLIElement>(null);\n  // Signed distance from the pointer to this tile's centre, updated from pointer events only.\n  const offset = useMotionValue(Infinity);\n  useMotionValueEvent(pointerX, \"change\", (x) => {\n    const el = ref.current;\n    if (!el || !Number.isFinite(x)) {\n      offset.set(Infinity);\n      return;\n    }\n    const rect = el.getBoundingClientRect();\n    offset.set(x - (rect.left + rect.width / 2));\n  });\n  const target = useTransform(offset, [-distance, 0, distance], [size, size * magnification, size]);\n  const width = useSpring(target, spring);\n\n  const focusMagnify = (event: React.FocusEvent<HTMLElement>) => {\n    // Keyboard focus magnifies like the pointer would; mouse clicks keep the pointer in charge.\n    if (!event.currentTarget.matches(\":focus-visible\")) return;\n    const rect = event.currentTarget.getBoundingClientRect();\n    pointerX.set(rect.left + rect.width / 2);\n  };\n\n  return (\n    <li ref={ref} className=\"group/tile relative flex shrink-0 flex-col items-center justify-end\">\n      <Tooltip label={item.label} index={index} />\n      <motion.div style={{ width, height: width }} className=\"relative\">\n        <DockLink item={item} className={TILE} onFocus={focusMagnify} onBlur={() => pointerX.set(Infinity)} />\n      </motion.div>\n      <Indicator shape={indicator} visible={!!item.active} className=\"-bottom-[6px]\" />\n    </li>\n  );\n}\n\nfunction Tooltip({ label, index }: { label: string; index: number }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute bottom-full left-1/2 mb-3 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-md border border-zinc-900/10 bg-white px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-700 opacity-0 shadow-[0_6px_16px_-8px_rgba(0,0,0,0.25)] transition-[opacity,translate] duration-150 group-hover/tile:translate-y-0 group-hover/tile:opacity-100 group-has-[:focus-visible]/tile:translate-y-0 group-has-[:focus-visible]/tile:opacity-100 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300\"\n    >\n      <span className=\"text-zinc-400 dark:text-zinc-500\">{String(index + 1).padStart(2, \"0\")}</span>\n      <span className=\"mx-1.5 text-zinc-300 dark:text-zinc-600\">/</span>\n      {label}\n    </span>\n  );\n}\n\n/* ---------------------------------- dock ---------------------------------- */\n\n/** Moves focus between dock items with the arrow keys, Home and End. */\nfunction handleArrowKeys(event: React.KeyboardEvent<HTMLElement>) {\n  const keys = [\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"];\n  if (!keys.includes(event.key)) return;\n  const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(\"[data-dock-item]\"));\n  const current = items.indexOf(document.activeElement as HTMLElement);\n  if (current === -1) return;\n  event.preventDefault();\n  const last = items.length - 1;\n  const next =\n    event.key === \"Home\" ? 0 : event.key === \"End\" ? last : event.key === \"ArrowRight\" ? (current + 1) % items.length : (current - 1 + items.length) % items.length;\n  items[next]?.focus();\n}\n\n/**\n * A navigation dock whose tiles grow as the pointer approaches. On touch devices, and with\n * reduced motion, it becomes a compact, horizontally scrollable bar with visible labels.\n */\nexport function InteractiveDock({\n  items,\n  magnification = 1.7,\n  distance = 140,\n  size = 44,\n  spring = { stiffness: 320, damping: 24, mass: 0.4 },\n  accent = \"#FF7448\",\n  indicator = \"dot\",\n  label = \"Dock\",\n  className = \"\",\n  style,\n  ...props\n}: InteractiveDockProps) {\n  const finePointer = useFinePointer();\n  const reduceMotion = usePrefersReducedMotion();\n  const pointerX = useMotionValue(Infinity);\n  const magnify = finePointer && !reduceMotion;\n  const accentStyle = { [\"--dock-accent\" as string]: accent, ...style };\n\n  if (!magnify) {\n    // Compact alternative: fixed tiles, labels always visible, scrolls horizontally if it overflows.\n    return (\n      <nav aria-label={label} className={`max-w-full ${className}`} style={accentStyle} {...props}>\n        <ul\n          onKeyDown={handleArrowKeys}\n          className=\"flex snap-x gap-1 overflow-x-auto rounded-2xl border border-zinc-900/10 bg-white/95 p-1.5 [scrollbar-width:none] dark:border-white/10 dark:bg-zinc-900/95\"\n        >\n          {items.map((item) => (\n            <li key={item.label} className=\"relative flex shrink-0 snap-start flex-col items-center gap-1 px-1 pb-2 pt-1\">\n              <div style={{ width: size, height: size }}>\n                <DockLink item={item} className={TILE} />\n              </div>\n              <span aria-hidden=\"true\" className=\"max-w-[4.5rem] truncate font-mono text-[9px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n                {item.label}\n              </span>\n              <Indicator shape={indicator} visible={!!item.active} className=\"bottom-0.5\" />\n            </li>\n          ))}\n        </ul>\n      </nav>\n    );\n  }\n\n  return (\n    <nav aria-label={label} className={className} style={accentStyle} {...props}>\n      <ul\n        onPointerMove={(event) => {\n          if (event.pointerType === \"mouse\") pointerX.set(event.clientX);\n        }}\n        onPointerLeave={() => pointerX.set(Infinity)}\n        onKeyDown={handleArrowKeys}\n        // Fixed bar height: magnified tiles rise above the bar instead of stretching it.\n        style={{ height: size + 20 }}\n        className=\"relative flex items-end gap-2.5 rounded-[20px] border border-zinc-900/10 bg-white/90 px-2.5 pb-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.8),0_18px_40px_-20px_rgba(0,0,0,0.35)] backdrop-blur-sm dark:border-white/10 dark:bg-zinc-950/80 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05),0_18px_40px_-20px_rgba(0,0,0,0.8)]\"\n      >\n        {/* Hairline baseline — an engineered detail that also anchors the indicators. */}\n        <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-4 bottom-[5px] h-px bg-zinc-900/[0.06] dark:bg-white/[0.06]\" />\n        {items.map((item, index) => (\n          <MagnifiedTile\n            key={item.label}\n            item={item}\n            index={index}\n            pointerX={pointerX}\n            size={size}\n            magnification={magnification}\n            distance={distance}\n            spring={spring}\n            indicator={indicator}\n          />\n        ))}\n      </ul>\n    </nav>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/interactive-dock"
}
