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.