Skip to content
Motion

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.

Loading preview…

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

0.35

Fraction of the pointer's offset from centre. Past ~0.6 it outruns the cursor.

24px

Invisible padding where the pull begins, before the pointer reaches the edge.

0.18

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.