ost of what separates a button that feels alive from one that just sits there is a few pixels of motion nobody consciously notices. Move the cursor near it and it drifts a little toward you; move away and it eases back. It reads as weight, like the button has physical presence, even though nothing about it changed shape. Every button on this site does that now — the trick is a small hook, not a library.
Thehardpartisn'tthepulltowardthecursor.It'smakingsurenothinghappensatallonaphone.
The mechanics
typescript
function useMagnetic(reach = 70, pull = 0.35) { const x = useSpring(0, { stiffness: 300, damping: 20, mass: 0.5 }); const y = useSpring(0, { stiffness: 300, damping: 20, mass: 0.5 }); useEffect(() => { const fine = window.matchMedia('(hover: hover) and (pointer: fine)'); if (!fine.matches) return; // touch devices: do nothing, ever const onMove = (e: MouseEvent) => { const rect = el.getBoundingClientRect(); const dx = e.clientX - (rect.left + rect.width / 2); const dy = e.clientY - (rect.top + rect.height / 2); const dist = Math.hypot(dx, dy); if (dist < reach) { x.set(dx * pull); y.set(dy * pull); } else { x.set(0); y.set(0); } }; window.addEventListener('mousemove', onMove, { passive: true }); return () => window.removeEventListener('mousemove', onMove); }, [reach, pull]); return { x, y }; }
Two spring-driven motion values, not raw state — that's what keeps it smooth. Setting x/y directly on every mousemove would fight the browser's paint cycle and feel jittery; a spring lets Framer Motion write straight to a CSS transform on the compositor thread every frame, so the button glides rather than snaps, and React never re-renders on mouse movement at all.
The part that matters more than the effect
The matchMedia check at the top isn't a nice-to-have — it's the reason this is safe to ship. hover: hover and pointer: fine both have to be true before a single listener attaches. No fine pointer, no hover capability, nothing runs: no wasted event listeners on mobile, no phantom offset that never resets because there was never a mouseleave to fire it. The effect only exists for the input device it makes sense on.
It also gets out of the way for anyone who's told their OS they don't want motion — a useReducedMotion() check inside the same hook bails before any listener is ever attached. A detail like this should be additive polish, never a tax on someone who explicitly opted out of it.