{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dock","type":"registry:component","title":"Dock","description":"Icons swell as your cursor nears them, and launch with a macOS-style bounce.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/dock.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n  type Transition,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst REST = 40;\nconst PEAK = 72;\n// How far from the cursor, in px, an icon still feels the pull.\nconst REACH = 140;\n// Light mass so the icons keep up with a fast cursor; low damping leaves a\n// small settle, which is what makes the dock feel springy rather than scripted.\nconst SIZE_SPRING = { mass: 0.1, stiffness: 170, damping: 12 };\n// The first label waits so a cursor passing through doesn't flash one.\nconst TOOLTIP_DELAY = 300;\n// The launch: two hops, the second lower, like something being thrown up\n// and caught. 700ms is well past the UI budget on purpose: it is the\n// \"starting up\" signal itself, it happens once per app, and input is never\n// blocked while it plays.\nconst HOP: { y: number[]; transition: Transition } = {\n  y: [0, -22, 0, -9, 0],\n  // Up fast and down with gravity; each landing is the quickest part.\n  transition: {\n    duration: 0.7,\n    times: [0, 0.3, 0.55, 0.78, 1],\n    ease: [\"easeOut\", \"easeIn\", \"easeOut\", \"easeIn\"],\n  },\n};\n// Squashes on each landing, so the icon reads as having weight.\nconst SQUASH: { scaleX: number[]; scaleY: number[]; transition: Transition } =\n  {\n    scaleY: [1, 1.04, 0.9, 1.02, 0.96, 1],\n    scaleX: [1, 0.97, 1.08, 0.99, 1.03, 1],\n    transition: { duration: 0.7, times: [0, 0.3, 0.55, 0.7, 0.8, 1] },\n  };\n\ntype Item = { label: string; icon: React.ReactNode };\ntype Tip = { label: string; instant: boolean };\n\nexport function Dock({\n  items,\n  defaultRunning = [],\n  onLaunch,\n}: {\n  items: Item[];\n  // Labels of the apps that start with a running light under them.\n  defaultRunning?: string[];\n  onLaunch?: (label: string) => void;\n}) {\n  const reduceMotion = useReducedMotion();\n  const mouseX = useMotionValue(Infinity);\n  const [tip, setTip] = useState<Tip | null>(null);\n  const [running, setRunning] = useState(defaultRunning);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const showTip = (label: string) => {\n    clearTimeout(timer.current);\n    // Once a label is up, moving along the dock swaps it with no delay and no\n    // animation, so scanning the icons feels instant.\n    if (tip) setTip({ label, instant: true });\n    else\n      timer.current = setTimeout(\n        () => setTip({ label, instant: false }),\n        TOOLTIP_DELAY,\n      );\n  };\n\n  const hideTip = () => {\n    clearTimeout(timer.current);\n    setTip(null);\n  };\n\n  return (\n    <nav\n      aria-label=\"Dock\"\n      className=\"flex h-16 items-end gap-2 rounded-full bg-muted px-3 pb-3 shadow-raised\"\n      onPointerMove={(e) => {\n        if (reduceMotion || e.pointerType === \"touch\") return;\n        mouseX.set(e.clientX);\n      }}\n      onPointerLeave={() => {\n        mouseX.set(Infinity);\n        hideTip();\n      }}\n    >\n      {items.map((item) => (\n        <DockItem\n          key={item.label}\n          item={item}\n          mouseX={mouseX}\n          tip={tip?.label === item.label ? tip : null}\n          onEnter={() => showTip(item.label)}\n          onFocus={() => setTip({ label: item.label, instant: false })}\n          onBlur={hideTip}\n          running={running.includes(item.label)}\n          onLaunch={() => {\n            setRunning((r) =>\n              r.includes(item.label) ? r : [...r, item.label],\n            );\n            onLaunch?.(item.label);\n          }}\n        />\n      ))}\n    </nav>\n  );\n}\n\nfunction DockItem({\n  item,\n  mouseX,\n  tip,\n  onEnter,\n  onFocus,\n  onBlur,\n  running,\n  onLaunch,\n}: {\n  item: Item;\n  mouseX: MotionValue<number>;\n  tip: Tip | null;\n  onEnter: () => void;\n  onFocus: () => void;\n  onBlur: () => void;\n  running: boolean;\n  onLaunch: () => void;\n}) {\n  const ref = useRef<HTMLButtonElement>(null);\n  const reduceMotion = useReducedMotion();\n  const hop = useRef<AnimationPlaybackControls[]>([]);\n  useEffect(() => () => hop.current.forEach((a) => a.stop()), []);\n\n  const distance = useTransform(mouseX, (x) => {\n    const box = ref.current?.getBoundingClientRect();\n    return box ? x - box.left - box.width / 2 : Infinity;\n  });\n  const target = useTransform(distance, [-REACH, 0, REACH], [REST, PEAK, REST]);\n  const size = useSpring(target, SIZE_SPRING);\n  const iconSize = useTransform(size, (s) => s * 0.45);\n\n  const launch = () => {\n    // Already running: a click just brings it forward, no fanfare.\n    if (running) return;\n    onLaunch();\n    if (reduceMotion || !ref.current) return;\n    hop.current.forEach((a) => a.stop());\n    hop.current = [\n      animate(ref.current, { y: HOP.y }, HOP.transition),\n      animate(\n        ref.current,\n        { scaleX: SQUASH.scaleX, scaleY: SQUASH.scaleY },\n        SQUASH.transition,\n      ),\n    ];\n  };\n\n  // Width and height animate instead of scale on purpose: the neighbours have\n  // to move apart, and a transform would let the icons overlap instead.\n  return (\n    <span className=\"relative flex shrink-0\">\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        aria-label={running ? `${item.label}, running` : item.label}\n        // Lands on its base, so the squash spreads along the dock.\n        style={{ width: size, height: size, originY: 1 }}\n        className=\"relative flex shrink-0 items-center justify-center rounded-full bg-background text-foreground shadow-raised outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\"\n        onPointerEnter={(e) => {\n          if (e.pointerType !== \"touch\") onEnter();\n        }}\n        onClick={launch}\n        onFocus={onFocus}\n        onBlur={onBlur}\n      >\n        <motion.svg\n          viewBox=\"0 0 24 24\"\n          style={{ width: iconSize, height: iconSize }}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden\n        >\n          {item.icon}\n        </motion.svg>\n      </motion.button>\n      {/* Outside the button, so the label holds still while the icon hops. */}\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute bottom-full left-1/2 mb-3 -translate-x-1/2 rounded-md bg-foreground px-2 py-1 text-xs font-medium whitespace-nowrap text-background transition-[opacity,translate] duration-150 ease-out\",\n          tip ? \"opacity-100\" : \"translate-y-1 opacity-0\",\n          tip?.instant && \"transition-none\",\n        )}\n      >\n        {item.label}\n      </span>\n      {/* The running light stays on the shelf while the icon hops, and\n          comes on as it first lands (0.55 of 700ms) rather than on click. */}\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute top-full left-1/2 mt-1 -ml-0.5 size-1 rounded-full bg-foreground transition-[opacity,scale] ease-[cubic-bezier(0.23,1,0.32,1)]\",\n          running\n            ? \"scale-100 opacity-100 delay-[385ms] duration-200 motion-reduce:delay-0\"\n            : \"scale-50 opacity-0 duration-150\",\n        )}\n      />\n    </span>\n  );\n}\n\nconst ITEMS: Item[] = [\n  {\n    label: \"Home\",\n    icon: <path d=\"M3.5 10.5 12 4l8.5 6.5V19a1 1 0 0 1-1 1H15v-5.5H9V20H4.5a1 1 0 0 1-1-1Z\" />,\n  },\n  {\n    label: \"Search\",\n    icon: (\n      <>\n        <circle cx=\"11\" cy=\"11\" r=\"6.5\" />\n        <path d=\"m20 20-4.35-4.35\" />\n      </>\n    ),\n  },\n  {\n    label: \"Mail\",\n    icon: (\n      <>\n        <rect x=\"3.5\" y=\"5.5\" width=\"17\" height=\"13\" rx=\"2\" />\n        <path d=\"m4 7 8 6 8-6\" />\n      </>\n    ),\n  },\n  {\n    label: \"Calendar\",\n    icon: (\n      <>\n        <rect x=\"3.5\" y=\"5\" width=\"17\" height=\"15\" rx=\"2\" />\n        <path d=\"M3.5 10h17M8 3v4M16 3v4\" />\n      </>\n    ),\n  },\n  {\n    label: \"Music\",\n    icon: (\n      <>\n        <path d=\"M9 18V5l11-2v13\" />\n        <circle cx=\"6\" cy=\"18\" r=\"3\" />\n        <circle cx=\"17\" cy=\"16\" r=\"3\" />\n      </>\n    ),\n  },\n  {\n    label: \"Settings\",\n    icon: (\n      <>\n        <path d=\"M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12\" />\n        <circle cx=\"16\" cy=\"6\" r=\"2\" />\n        <circle cx=\"10\" cy=\"12\" r=\"2\" />\n        <circle cx=\"18\" cy=\"18\" r=\"2\" />\n      </>\n    ),\n  },\n];\n\nexport default function DockDemo() {\n  return <Dock items={ITEMS} defaultRunning={[\"Home\", \"Mail\"]} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/dock"}