{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"magnetic-button","type":"registry:component","title":"Magnetic button","description":"Stretches toward your cursor like soft rubber on a magnet, then wobbles home.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/magnetic-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useRef } from \"react\";\nimport { motion, useMotionValue, useSpring, useTransform } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// How far past its edges the button starts to feel the cursor, in px.\nconst FIELD = 96;\n// Share of the cursor's offset the button follows at full strength.\nconst STRENGTH = 0.5;\n// Light and a little loose, so it follows like it's on a short elastic.\nconst FOLLOW = { stiffness: 180, damping: 14, mass: 0.2 };\n// The label travels further than the button, so it reads as a layer above it.\nconst LABEL_DEPTH = 0.5;\n// The body stretches toward the pull like something soft on a magnet: 1% per\n// 1.2px of pull, capped at 12% so the pill never turns into a blob. It thins\n// by half as much across, which roughly keeps its area.\nconst STRETCH_PER_PX = 1 / 120;\nconst MAX_STRETCH = 0.12;\n// Much looser than the follow (damping ratio about 0.4), so when the cursor\n// lets go the pill jiggles through a squash or two before it settles.\nconst WOBBLE = { stiffness: 320, damping: 10, mass: 0.5 };\n\nexport function MagneticButton({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"button\">) {\n  const reduceMotion = useReducedMotion();\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const pullX = useMotionValue(0);\n  const pullY = useMotionValue(0);\n  const x = useSpring(pullX, FOLLOW);\n  const y = useSpring(pullY, FOLLOW);\n  const stretchTarget = useTransform(() =>\n    Math.min(Math.hypot(x.get(), y.get()) * STRETCH_PER_PX, MAX_STRETCH),\n  );\n  const stretch = useSpring(stretchTarget, WOBBLE);\n  // The pull's direction, held once the pull fades out: a spring settling\n  // around zero has no direction of its own, and the jiggle should stay on\n  // the axis the pill was stretched along.\n  const heading = useRef(0);\n  const angle = useTransform(() => {\n    const dx = x.get();\n    const dy = y.get();\n    if (Math.hypot(dx, dy) > 1) heading.current = Math.atan2(dy, dx);\n    return heading.current;\n  });\n  // Stretch along the pull: turn to its axis, scale, turn back.\n  const transform = useTransform(() => {\n    const s = stretch.get();\n    const a = angle.get();\n    return `translate(${x.get()}px, ${y.get()}px) rotate(${a}rad) scale(${1 + s}, ${1 - s / 2}) rotate(${-a}rad)`;\n  });\n  // The exact inverse of the stretch, so the label never distorts, plus its\n  // own parallax.\n  const labelTransform = useTransform(() => {\n    const s = stretch.get();\n    const a = angle.get();\n    return `translate(${x.get() * LABEL_DEPTH}px, ${y.get() * LABEL_DEPTH}px) rotate(${a}rad) scale(${1 / (1 + s)}, ${1 / (1 - s / 2)}) rotate(${-a}rad)`;\n  });\n\n  const release = () => {\n    pullX.set(0);\n    pullY.set(0);\n  };\n\n  return (\n    // The padding is the magnetic field: it catches the cursor before it\n    // reaches the button. The matching negative margin keeps it out of the\n    // layout, so the page only makes room for the button itself.\n    <div\n      style={{ padding: FIELD, margin: -FIELD }}\n      onPointerMove={(e) => {\n        if (reduceMotion || e.pointerType === \"touch\") return;\n        const box = buttonRef.current?.getBoundingClientRect();\n        if (!box) return;\n        const dx = e.clientX - (box.left + box.width / 2);\n        const dy = e.clientY - (box.top + box.height / 2);\n        const reach = Math.max(box.width, box.height) / 2 + FIELD;\n        const t = Math.min(Math.hypot(dx, dy) / reach, 1);\n        // Weakens with distance like a real magnet, reaching zero at the edge\n        // of the field so entering or leaving it never jolts the button.\n        const strength = STRENGTH * (1 - t) ** 2;\n        pullX.set(dx * strength);\n        pullY.set(dy * strength);\n      }}\n      onPointerLeave={release}\n    >\n      <motion.div style={{ transform }}>\n        <button\n          ref={buttonRef}\n          className={cn(\n            \"h-12 rounded-full bg-foreground px-6 text-sm font-medium whitespace-nowrap text-background transition-[scale] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-none\",\n            className,\n          )}\n          {...props}\n        >\n          <motion.span\n            className=\"flex items-center gap-2\"\n            style={{ transform: labelTransform }}\n          >\n            {children}\n          </motion.span>\n        </button>\n      </motion.div>\n    </div>\n  );\n}\n\nexport default function MagneticButtonDemo() {\n  return (\n    <MagneticButton type=\"button\">\n      Get in touch\n      <svg\n        viewBox=\"0 0 16 16\"\n        className=\"size-4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n      >\n        <path d=\"M3 8h10M9 4l4 4-4 4\" />\n      </svg>\n    </MagneticButton>\n  );\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"}],"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/magnetic-button"}