Building a magnetic cursor for React
There's a particular flourish on a lot of well-made agency sites: the cursor isn't quite yours. As you drift toward a button, it leans in, catches, and wraps around the thing you're about to click. It's a small detail, but it makes an interface feel intentional — like the page is reaching back toward you.
I'd reimplemented it from scratch one too many times, so I turned it into a library:
magnet-cursor. Render one component, tag the elements you want to be sticky with
data-magnetic, and the cursor does the rest.
What it does
The whole API is meant to disappear:
import { MagnetCursor } from 'magnet-cursor';
<>
<MagnetCursor color="#7c5cff" hideNativeCursor />
<button data-magnetic>Get started</button>
</>One <MagnetCursor /> near the root listens to the document, and any element matching
the selector (default [data-magnetic]) becomes a magnet. The closest one within
radius wins, the cursor eases toward its center, and it swells to "swallow" the target.
If you'd rather own the markup, there's a headless useMagnetCursor() hook that hands you
the props and an isPulling flag.
It's about 1.5 kB, has no runtime dependencies, and works under React 18 and 19. A couple of decisions made it nicer than my old copy-pasted versions.
Decision 1: never re-render on pointer move
The naive approach is to keep the cursor position in React state and update it on every
pointermove. That's a re-render per pixel of mouse travel — death by a thousand commits,
and completely unnecessary, because the position isn't logic, it's paint.
So position never touches React. The move handler writes it straight to CSS custom properties on the cursor node:
node.style.setProperty('--mc-x', `${x}px`);
node.style.setProperty('--mc-y', `${y}px`);The element's transform reads those variables, and a CSS transition on the transform
gives you the smooth "easing toward the magnet" feel for free. React state only flips for
things that are genuinely state: whether the cursor is currently locked onto something
(so it can morph) and whether the pointer is even inside the document yet.
Decision 2: pull from the edge, not the center
The pull math is a proximity-weighted lerp. The interesting bit is measuring distance from the element's edge, not its center:
const dx = Math.max(rect.left - x, 0, x - rect.right);
const dy = Math.max(rect.top - y, 0, y - rect.bottom);
const dist = Math.hypot(dx, dy); // 0 while the pointer is inside the boxA flat radius from the center would mean a big button's own corners fall outside its pull
zone — the magnet would feel weaker on larger elements, which is backwards. Measuring from
the edge gives every target a size-aware halo automatically: radius is "how far past the
border the pull reaches," and once you're inside, proximity is maxed and the cursor snaps
to center.
The bug that almost shipped: StrictMode
Here's the one that bites everybody. Effects like this need to set up a listener and tear
it down. The tempting shape is to do the setup in a ref callback and the teardown in a
useEffect. Don't.
React 18 and 19 StrictMode simulate an unmount/remount in development to flush out exactly
this kind of code. During that dance the useEffect cleanup runs — killing your listener —
but the ref callback does not fire again on the simulated remount. Your observer is gone,
permanently, and nothing tells you. jsdom doesn't reproduce the cycle, so your tests stay
green while the real app is dead.
The fix is to keep the node in state and put the entire lifecycle inside one effect:
const [node, setNode] = useState<HTMLDivElement | null>(null);
const ref = useCallback((n) => setNode(n), []);
useEffect(() => {
if (!node || !isActive) return;
document.addEventListener('pointermove', handleMove);
document.addEventListener('mouseleave', handleLeave);
return () => {
document.removeEventListener('pointermove', handleMove);
document.removeEventListener('mouseleave', handleLeave);
};
}, [node, isActive, selector, radius, strength]);When node attaches it triggers a re-render, the effect runs, and StrictMode can safely
re-run setup after each cleanup. Everything that can be set up can be torn down.
Accessibility, for free
The cursor is aria-hidden and pointer-events: none, so it never enters the
accessibility tree or steals a click. It treats itself as pure decoration: under
prefers-reduced-motion: reduce it switches off and the native cursor takes over, and on
touch devices with no pointer it just stays hidden. Your UI works identically without it —
it's a progressive enhancement, not a dependency.
Try it
pnpm add magnet-cursorThere's a live playground where you can drag every prop around and feel the pull change in real time. Move your mouse near anything on the page and you'll get it immediately.
What's next
A few ideas I'm sitting on: per-element overrides (a stronger pull on the primary CTA than on a footer link), an optional spring instead of a CSS transition for the follow, and a "snap to grid of nearest N" mode for dense toolbars. If you build something fun with it, I'd love to see it.
End of essay



