{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "magnetic-button",
  "title": "Magnetic Button",
  "author": "HoverUI (https://hoverui.com)",
  "description": "A button that pulls toward the cursor and springs back on leave. Its pointer field extends ~8px past the visual bounds — keep that much clearance from other interactive elements.",
  "files": [
    {
      "path": "registry/hover/magnetic-button/magnetic-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\ntype MagneticButtonProps = React.ComponentPropsWithoutRef<\"button\"> & {\n  /**\n   * How far the button travels toward the cursor.\n   * 0 = inert, 1 = the cursor's exact offset. Past ~0.6 it stops reading as magnetism\n   * and starts reading as a bug.\n   */\n  strength?: number;\n};\n\n/** Cursor tracking: fast enough to feel attached, slow enough to smooth pointer jitter. */\nconst TRACK = \"120ms\";\nconst TRACK_EASE = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\n/** Release: slower, with a slight overshoot. This is where the effect gets its character. */\nconst RELEASE = \"420ms\";\nconst RELEASE_EASE = \"cubic-bezier(0.34, 1.4, 0.64, 1)\";\n\n/**\n * G7's single query, subscribed rather than copied into state by an effect.\n *\n * The previous shape read the query in an effect body and called setState with the\n * result, which React 19 rejects (react-hooks/set-state-in-effect) and which rendered the\n * component twice on every mount: once inert, then again armed. useSyncExternalStore\n * subscribes to the same MediaQueryList with no intermediate render.\n *\n * These live at module scope because useSyncExternalStore compares the subscribe function\n * by identity — declared inside the component they would be new on every render and\n * resubscribe in a loop.\n */\nconst MAGNET_QUERY =\n  \"(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)\";\n\nconst subscribeMagnet = (onStoreChange: () => void) => {\n  const mq = window.matchMedia(MAGNET_QUERY);\n  mq.addEventListener(\"change\", onStoreChange);\n  return () => mq.removeEventListener(\"change\", onStoreChange);\n};\n\nconst getMagnet = () => window.matchMedia(MAGNET_QUERY).matches;\n\n// Inert on the server — there is no cursor there. This must equal the client's\n// pre-hydration value or the first paint is a hydration mismatch, and false was already\n// what useState was seeded with.\nconst getMagnetServer = () => false;\n\nexport function MagneticButton({\n  strength = 0.4,\n  className,\n  children,\n  ...props\n}: MagneticButtonProps) {\n  const target = React.useRef<HTMLButtonElement>(null);\n  // Touch devices fire hover on tap and then keep it, which strands the effect\n  // mid-travel. Only arm the magnet for a real cursor, and never under reduced motion —\n  // the handler must not compute vars that CSS would only hide (G7 + G10).\n  const magnetic = React.useSyncExternalStore(\n    subscribeMagnet,\n    getMagnet,\n    getMagnetServer,\n  );\n\n  // Written straight to the node. A setState here would re-render at pointer frequency.\n  const track = (event: React.PointerEvent<HTMLSpanElement>) => {\n    const el = target.current;\n    if (!el || !magnetic) return;\n    const box = el.getBoundingClientRect();\n    const x = (event.clientX - (box.left + box.width / 2)) * strength;\n    const y = (event.clientY - (box.top + box.height / 2)) * strength;\n    el.style.setProperty(\"--hui-x\", `${x.toFixed(2)}px`);\n    el.style.setProperty(\"--hui-y\", `${y.toFixed(2)}px`);\n    el.style.setProperty(\"--hui-duration\", TRACK);\n    el.style.setProperty(\"--hui-ease\", TRACK_EASE);\n  };\n\n  const release = () => {\n    const el = target.current;\n    if (!el) return;\n    el.style.setProperty(\"--hui-x\", \"0px\");\n    el.style.setProperty(\"--hui-y\", \"0px\");\n    el.style.setProperty(\"--hui-duration\", RELEASE);\n    el.style.setProperty(\"--hui-ease\", RELEASE_EASE);\n  };\n\n  return (\n    // Padding extends the magnetic field ~8px beyond the button; the negative margin\n    // cancels its effect on layout, so dropping this in never shifts a page. The hit\n    // area still reaches past the visual bounds — keep ~8px of clearance from other\n    // interactive elements, or neighbours will fight over the pointer.\n    <span\n      className=\"inline-flex -m-2 p-2\"\n      onPointerMove={track}\n      onPointerLeave={release}\n    >\n      <button\n        ref={target}\n        style={\n          {\n            \"--hui-x\": \"0px\",\n            \"--hui-y\": \"0px\",\n            \"--hui-duration\": RELEASE,\n            \"--hui-ease\": RELEASE_EASE,\n          } as React.CSSProperties\n        }\n        className={[\n          \"[transform:translate3d(var(--hui-x),var(--hui-y),0)]\",\n          \"[transition:transform_var(--hui-duration)_var(--hui-ease)]\",\n          // Press feedback. Keeps the magnetic offset so the button doesn't jump.\n          \"active:[transform:translate3d(var(--hui-x),var(--hui-y),0)_scale(0.97)]\",\n          // Keyboard users have no cursor, so they get the arrival state, not tracking.\n          // Two selector components, so this outranks the base transform under G16.\n          \"focus-visible:[transform:scale(1.04)] focus-visible:outline-none\",\n          // No ring offset: its default colour is white, which halos on any non-white\n          // page background.\n          \"focus-visible:ring-2 focus-visible:ring-current\",\n          // A press with no prior pointer move would otherwise inherit the 420ms spring,\n          // which reads as mushy. Own duration, inside the G3 press band.\n          \"active:[transition:transform_140ms_cubic-bezier(0.23,1,0.32,1)]\",\n          // Suppresses the resting travel only. It cannot and need not override the\n          // focus or active rules above — under reduced motion the magnet never arms,\n          // so --hui-x/y stay 0px and those rules already resolve cleanly (G16).\n          \"motion-reduce:[transform:none]\",\n          className,\n        ]\n          .filter(Boolean)\n          .join(\" \")}\n        {...props}\n      >\n        {children}\n      </button>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/hover/magnetic-button.tsx"
    }
  ],
  "categories": [
    "button"
  ],
  "type": "registry:component"
}