Issue №131 Summer 2026A journal of dev tools, libraries & small ideasUpdated weekly
frontend · 5 min read
frontendJune 29, 2026 · 5 min

magnet-cursor

magnet-cursor is a tiny React library for the "magnetic cursor" interaction you see on award-winning sites: a custom cursor that glides across the page and snaps onto your buttons and links as the pointer drifts near them. You render one component, tag the elements you want to feel sticky with `data

magnet-cursor — live demo screenshot
01
frontend

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 box

A 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-cursor

There'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

About the author
Er An Khoo