Skip to content
Knsept
Free playbook
JavaScriptUI Widgets

A magnetic button in 30 lines of vanilla JS

The cursor-attracting button you see on award-winning sites, built without a library. Works with mouse, touch, and reduced motion.

Robert Hemingway
By Robert HemingwayDeveloper & builder
Oct 2, 20262 min read

A magnetic button leans toward your cursor as you get close, then springs back when you leave. It’s a small touch, but it makes an interface feel physical.

You don’t need a library for it. You need one event listener, one bit of math, and a CSS transition.

The idea

When the pointer is inside an “attraction zone” around the button, move the button a fraction of the distance toward the pointer. When the pointer leaves, reset it. CSS handles the easing.

The markup and style

<button class="magnet"><span>Hover me</span></button>
.magnet {
  transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.magnet span {
  display: inline-block;
  transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}

The inner span moves a little further than the button. That parallax is what sells the effect.

The script

const strength = 0.35;
const reach = 80; // px around the button that attracts

document.querySelectorAll('.magnet').forEach((btn) => {
  const label = btn.querySelector('span');

  const move = (e) => {
    const r = btn.getBoundingClientRect();
    const dx = e.clientX - (r.left + r.width / 2);
    const dy = e.clientY - (r.top + r.height / 2);
    const inZone = Math.abs(dx) < r.width / 2 + reach && Math.abs(dy) < r.height / 2 + reach;

    if (!inZone) return reset();
    btn.style.transform = `translate(${dx * strength}px, ${dy * strength}px)`;
    label.style.transform = `translate(${dx * strength * 0.5}px, ${dy * strength * 0.5}px)`;
  };
  const reset = () => { btn.style.transform = ''; label.style.transform = ''; };

  window.addEventListener('pointermove', move, { passive: true });
  btn.addEventListener('pointerleave', reset);
});

Make it work everywhere

A magnet that only works with a mouse looks broken on a phone. Two additions fix that.

Touch: there's no hover, so follow the finger while it presses and drags the button. Pointer capture keeps the events coming even when the finger slides off, and touch-action: none stops the drag from scrolling the page.

btn.addEventListener('pointerdown', (e) => { if (e.pointerType === 'touch') btn.setPointerCapture(e.pointerId); });
btn.addEventListener('pointermove', (e) => { if (e.pointerType === 'touch') pull(e.clientX, e.clientY); });
btn.addEventListener('pointerup', reset);

Reduced motion: don't switch the effect off, because then it just looks broken. Turn it down. A third of the pull and no label parallax still feels responsive without big movement.

const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const strength = reduced ? 0.12 : 0.35;

Try it

There’s a live, copy-ready version in the library: Magnetic button.

Found this useful? Share it, or follow new posts in your reader.

Robert Hemingway
Written by

Robert Hemingway

I build small, sharp tools and write down what I learn along the way: front-end craft, edge deploys, AI-assisted workflows, and the systems that hold them together.

About me