Magnetic
A wrapper that draws its children toward the pointer as it approaches and releases them when it leaves. Three details separate it from the usual version: it listens on the element rather than on window, so twenty magnets cost twenty local listeners instead of one global handler measuring distance to all of them; the position eases toward its target in a rAF loop instead of being written straight from the event, so it has mass rather than jittering at the mouse sample rate; and the transform goes on an inner span, so the interactive element's own hit box never slides out from under the click chasing it.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Magnetic } from "@/components/magnetic"; <Magnetic> <button className="rounded-full border px-6 py-3">Get in touch</button></Magnetic>Customise
Fraction of the pointer's offset from centre. Past ~0.6 it outruns the cursor.
Invisible padding where the pull begins, before the pointer reaches the edge.
Lower is heavier and lags further behind the cursor.
The component
components/craft/motion/Magnetic.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * Pulls whatever you wrap toward the cursor as it approaches. * * Three things separate this from the usual implementation: * * It listens on the element, not on window. The common version attaches a * global pointermove and measures distance to every magnet on the page — * fine with one button, quadratic with twenty. A pointerenter on the padded * hit area costs nothing until the pointer is actually near. * * The position is eased toward the target in a rAF loop rather than written * straight from the event. Assigning the raw pointer delta makes the element * snap and jitter at exactly the sample rate of the mouse; a spring makes it * feel like it has mass. * * The transform goes on an inner span, leaving the outer element's box where * the browser put it. Translating the interactive element itself moves its * hit box away from the cursor, so a strongly-magnetised button can slide out * from under the click that was chasing it. */ export interface MagneticProps { children: React.ReactNode; /** How far the element travels, as a fraction of pointer offset. */ strength?: number; /** Pixels beyond the element's box where the pull begins. */ radius?: number; /** 0–1. Lower is heavier and lags further behind the cursor. */ ease?: number; still?: boolean; className?: string;} export function Magnetic({ children, strength = 0.35, radius = 24, ease = 0.18, still = false, className = "",}: MagneticProps) { const hostRef = useRef<HTMLSpanElement>(null); const moverRef = useRef<HTMLSpanElement>(null); useEffect(() => { const host = hostRef.current; const mover = moverRef.current; if (!host || !mover) return; const reduced = still || (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); if (reduced) return; // A coarse pointer has no hover state to anticipate, so the effect is // meaningless on touch and the listeners are not worth attaching. if (typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches) { return; } let targetX = 0; let targetY = 0; let currentX = 0; let currentY = 0; let frame = 0; let running = false; const tick = () => { currentX += (targetX - currentX) * ease; currentY += (targetY - currentY) * ease; mover.style.transform = `translate3d(${currentX.toFixed(2)}px, ${currentY.toFixed(2)}px, 0)`; // Stop the loop once it has settled, instead of idling forever at // sub-pixel deltas. A page of magnets would otherwise keep rAF busy // for nothing. if (Math.abs(targetX - currentX) < 0.05 && Math.abs(targetY - currentY) < 0.05) { mover.style.transform = `translate3d(${targetX}px, ${targetY}px, 0)`; running = false; return; } frame = requestAnimationFrame(tick); }; const start = () => { if (running) return; running = true; frame = requestAnimationFrame(tick); }; const onMove = (event: PointerEvent) => { const box = host.getBoundingClientRect(); const cx = box.left + box.width / 2; const cy = box.top + box.height / 2; targetX = (event.clientX - cx) * strength; targetY = (event.clientY - cy) * strength; start(); }; const onLeave = () => { targetX = 0; targetY = 0; start(); }; host.addEventListener("pointermove", onMove); host.addEventListener("pointerleave", onLeave); return () => { host.removeEventListener("pointermove", onMove); host.removeEventListener("pointerleave", onLeave); cancelAnimationFrame(frame); mover.style.transform = ""; }; }, [strength, radius, ease, still]); return ( <span ref={hostRef} className={`inline-block ${className}`} // Padding extends the hit area so the pull starts before the pointer // reaches the visible edge; the negative margin keeps that invisible // to layout. style={{ padding: radius, margin: -radius }} > <span ref={moverRef} className="inline-block will-change-transform"> {children} </span> </span> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+1.4KB gzipped · no new dependencies
- Runs on
Compositor
Animates transform and opacity only, so the compositor handles it off the main thread. Scroll and input stay responsive even while it runs.
- Reduced motion
No listeners attached and no transform applied. The element sits exactly where layout put it.
- Accessibility
- Adds no tab stops and no roles — whatever you wrap keeps its own semantics.
- The transform is on an inner span, so the focus ring and hit box stay put.
- Skipped entirely on coarse pointers, where there is no hover to anticipate.
- Watch out
- The rAF loop stops once the spring settles rather than idling forever, but a page with dozens of these still has dozens of potential loops.
- The padding that extends the hit area is cancelled with a negative margin. If the wrapper sits in a tight grid, check it does not overlap a neighbour's pointer target.