{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dock","title":"Dock","author":"CoDesign-Spa27","description":"A macOS-style magnifying dock powered by shared motion values and spring-smoothed icon sizing.","dependencies":["motion"],"files":[{"path":"components/ui-components/dock.tsx","content":"\"use client\";\n\nimport {\n  type MotionStyle,\n  type MotionValue,\n  motion,\n  useAnimationFrame,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  useVelocity,\n} from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useRef } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface DockItem {\n  id: string;\n  label: string;\n  icon: ReactNode;\n  onClick?: () => void;\n}\n\nexport interface DockProps {\n  items: readonly DockItem[];\n  ariaLabel?: string;\n  baseSize?: number;\n  className?: string;\n  colors?: DockColors;\n  influence?: number;\n  layout?: DockLayout;\n  maxSize?: number;\n  motion?: DockMotion;\n}\n\nexport type DockSpring = {\n  bounce?: number;\n  damping?: number;\n  mass?: number;\n  stiffness?: number;\n  visualDuration?: number;\n};\n\nexport type DockColors = {\n  focusRing?: string;\n  highlight?: string;\n  icon?: string;\n  item?: string;\n  surface?: string;\n};\n\nexport type DockLayout = {\n  gap?: number;\n  height?: number;\n  iconScale?: number;\n  itemRadius?: number;\n  padding?: number;\n  radius?: number;\n};\n\nexport type DockMotion = {\n  highlightSpring?: DockSpring;\n  jelly?: number;\n  sizeSpring?: DockSpring;\n  tapScale?: number;\n  visibilitySpring?: DockSpring;\n};\n\ntype PhysicalSpring = Required<Pick<DockSpring, \"damping\" | \"mass\" | \"stiffness\">>;\ntype DockButtonStyle = MotionStyle & { \"--tw-ring-color\"?: string };\n\nconst DEFAULT_SIZE_SPRING = {\n  mass: 0.1,\n  stiffness: 170,\n  damping: 14,\n} satisfies PhysicalSpring;\n\nconst DEFAULT_HIGHLIGHT_SPRING = {\n  mass: 0.22,\n  stiffness: 200,\n  damping: 20,\n} satisfies PhysicalSpring;\n\nconst DEFAULT_VISIBILITY_SPRING = {\n  mass: 1,\n  stiffness: 200,\n  damping: 25,\n} satisfies PhysicalSpring;\n\nconst DEFAULT_LAYOUT = {\n  gap: 12,\n  height: 40,\n  iconScale: 0.55,\n  itemRadius: 12,\n  padding: 8,\n  radius: 16,\n} satisfies Required<DockLayout>;\n\nfunction resolveSpring(spring: DockSpring | undefined, fallback: PhysicalSpring) {\n  if (spring?.visualDuration !== undefined || spring?.bounce !== undefined) {\n    return {\n      bounce: spring.bounce ?? 0,\n      visualDuration: spring.visualDuration ?? 0.35,\n    };\n  }\n\n  return {\n    damping: spring?.damping ?? fallback.damping,\n    mass: spring?.mass ?? fallback.mass,\n    stiffness: spring?.stiffness ?? fallback.stiffness,\n  };\n}\n\nexport function Dock({\n  items,\n  ariaLabel = \"Dock\",\n  baseSize = 44,\n  className = \"\",\n  colors,\n  influence = 140,\n  layout,\n  maxSize = 80,\n  motion: motionConfig,\n}: DockProps) {\n  const reduceMotion = useReducedMotion();\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  // The DOM node of the currently hovered/focused button. A ref, not state,\n  // so sweeping the mouse never triggers a React re-render.\n  const activeRef = useRef<HTMLButtonElement | null>(null);\n\n  const mouseX = useMotionValue(Infinity);\n\n  const targetX = useMotionValue(0);\n  const targetY = useMotionValue(0);\n  const targetW = useMotionValue(baseSize);\n  const targetH = useMotionValue(baseSize);\n  const targetOpacity = useMotionValue(0);\n\n  const resolvedLayout = { ...DEFAULT_LAYOUT, ...layout };\n  const highlightSpringConfig = resolveSpring(\n    motionConfig?.highlightSpring,\n    DEFAULT_HIGHLIGHT_SPRING,\n  );\n  const visibilitySpringConfig = resolveSpring(\n    motionConfig?.visibilitySpring,\n    DEFAULT_VISIBILITY_SPRING,\n  );\n  const xSpring = useSpring(targetX, highlightSpringConfig);\n  const ySpring = useSpring(targetY, highlightSpringConfig);\n  const wSpring = useSpring(targetW, highlightSpringConfig);\n  const hSpring = useSpring(targetH, highlightSpringConfig);\n  const opacitySpring = useSpring(targetOpacity, visibilitySpringConfig);\n\n  const x = reduceMotion ? targetX : xSpring;\n  const y = reduceMotion ? targetY : ySpring;\n  const width = reduceMotion ? targetW : wSpring;\n  const height = reduceMotion ? targetH : hSpring;\n  const opacity = reduceMotion ? targetOpacity : opacitySpring;\n  const handleActivate = useCallback((element: HTMLButtonElement | null) => {\n    activeRef.current = element;\n  }, []);\n\n  useAnimationFrame(() => {\n    const el = activeRef.current;\n    const container = containerRef.current;\n    if (!el || !container || !el.isConnected) {\n      activeRef.current = null;\n      targetOpacity.set(0);\n      return;\n    }\n    const cb = container.getBoundingClientRect();\n    const b = el.getBoundingClientRect();\n    targetX.set(b.left - cb.left);\n    targetY.set(b.top - cb.top);\n    targetW.set(b.width);\n    targetH.set(b.height);\n    targetOpacity.set(1);\n  });\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"relative mx-auto flex items-end bg-neutral-200 shadow-2xl shadow-black/40 backdrop-blur-md dark:bg-neutral-900\",\n        className,\n      )}\n      role=\"toolbar\"\n      aria-label={ariaLabel}\n      style={{\n        backgroundColor: colors?.surface,\n        borderRadius: resolvedLayout.radius,\n        gap: resolvedLayout.gap,\n        height: resolvedLayout.height,\n        padding: resolvedLayout.padding,\n      }}\n      onMouseMove={(event) => mouseX.set(event.clientX)}\n      onMouseLeave={() => {\n        mouseX.set(Infinity);\n        activeRef.current = null;\n      }}\n      // Clear only when focus leaves the dock entirely, so keyboard\n      // tabbing slides the highlight instead of dropping it.\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node)) {\n          activeRef.current = null;\n        }\n      }}\n    >\n      {/* The single highlight. It lives behind every button and is positioned\n          purely by motion values — never mounted/unmounted, never projected,\n          so it can't flicker against the icons in either direction. */}\n      <motion.div\n        aria-hidden\n        style={{\n          x,\n          y,\n          width,\n          height,\n          opacity,\n          backgroundColor: colors?.highlight,\n          borderRadius: resolvedLayout.itemRadius,\n        }}\n        className=\"pointer-events-none absolute left-0 top-0 z-20 bg-neutral-300 shadow-lg shadow-black/20 dark:bg-neutral-700\"\n      />\n\n      {items.map((item) => (\n        <DockButton\n          key={item.id}\n          item={item}\n          mouseX={mouseX}\n          baseSize={baseSize}\n          maxSize={maxSize}\n          influence={influence}\n          colors={colors}\n          iconScale={resolvedLayout.iconScale}\n          itemRadius={resolvedLayout.itemRadius}\n          jelly={motionConfig?.jelly ?? 0.08}\n          onActivate={handleActivate}\n          sizeSpring={motionConfig?.sizeSpring}\n          tapScale={motionConfig?.tapScale ?? 0.88}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction DockButton({\n  item,\n  mouseX,\n  baseSize,\n  maxSize,\n  influence,\n  colors,\n  iconScale,\n  itemRadius,\n  jelly,\n  onActivate,\n  sizeSpring,\n  tapScale,\n}: {\n  item: DockItem;\n  mouseX: MotionValue<number>;\n  baseSize: number;\n  maxSize: number;\n  influence: number;\n  colors?: DockColors;\n  iconScale: number;\n  itemRadius: number;\n  jelly: number;\n  onActivate: (el: HTMLButtonElement | null) => void;\n  sizeSpring?: DockSpring;\n  tapScale: number;\n}) {\n  const ref = useRef<HTMLButtonElement | null>(null);\n  const reduceMotion = useReducedMotion();\n  const restingSize = useMotionValue(baseSize);\n  const expandedSize = useMotionValue(maxSize);\n  const influenceDistance = useMotionValue(influence);\n\n  const distance = useTransform(mouseX, (x) => {\n    const bounds = ref.current?.getBoundingClientRect();\n    if (!bounds) return Infinity;\n    return x - (bounds.x + bounds.width / 2);\n  });\n\n  const targetSize = useTransform(\n    [distance, restingSize, expandedSize, influenceDistance],\n    ([latestDistance, latestBaseSize, latestMaxSize, latestInfluence]: number[]) => {\n      const proximity = 1 - Math.min(Math.abs(latestDistance) / latestInfluence, 1);\n      return latestBaseSize + (latestMaxSize - latestBaseSize) * proximity;\n    },\n  );\n\n  useEffect(() => {\n    restingSize.set(baseSize);\n    expandedSize.set(maxSize);\n    influenceDistance.set(Math.max(influence, 1));\n  }, [baseSize, expandedSize, influence, influenceDistance, maxSize, restingSize]);\n\n  const sizeSpringConfig = resolveSpring(sizeSpring, DEFAULT_SIZE_SPRING);\n  // Size is owned solely by this spring — nothing competes with it.\n  const size = useSpring(reduceMotion ? restingSize : targetSize, sizeSpringConfig);\n\n  // Subtle \"jelly\" squish from how fast the icon is growing/shrinking.\n  const sizeVelocity = useVelocity(size);\n  const iconScaleX = useTransform(\n    sizeVelocity,\n    [-500, 0, 500],\n    reduceMotion ? [1, 1, 1] : [1 - jelly, 1, 1 + jelly],\n  );\n  const iconScaleY = useTransform(\n    sizeVelocity,\n    [-500, 0, 500],\n    reduceMotion ? [1, 1, 1] : [1 + jelly * 0.75, 1, 1 - jelly * 0.5],\n  );\n  const buttonStyle: DockButtonStyle = {\n    width: size,\n    height: size,\n    backgroundColor: colors?.item,\n    borderRadius: itemRadius,\n    color: colors?.icon,\n    \"--tw-ring-color\": colors?.focusRing,\n  };\n\n  return (\n    <motion.button\n      ref={ref}\n      type=\"button\"\n      style={buttonStyle}\n      onClick={item.onClick}\n      onHoverStart={() => onActivate(ref.current)}\n      onFocus={() => onActivate(ref.current)}\n      whileTap={reduceMotion ? undefined : { scale: tapScale }}\n      // Transparent + z-10 so the shared highlight shows through behind it.\n      className=\"group relative grid place-items-center bg-neutral-400 text-zinc-200 outline-none ring-cyan-400/70 transition-colors focus-visible:ring-2 dark:bg-neutral-800\"\n      aria-label={item.label}\n    >\n      <motion.span\n        style={{\n          scaleX: iconScaleX,\n          scaleY: iconScaleY,\n          transformOrigin: \"center\",\n          width: `${iconScale * 100}%`,\n          height: `${iconScale * 100}%`,\n        }}\n        className=\"pointer-events-none z-30 flex items-center justify-center\"\n      >\n        {item.icon}\n      </motion.span>\n    </motion.button>\n  );\n}\n","type":"registry:ui"}],"categories":["navigation","dock"],"type":"registry:ui"}