Picture a bike shop page with one job: get people to book a spring tune up. The button is there, but it looks like every other button on the internet, and visitors scroll past it. A magnetic button gives that one action a small sign of life. As the mouse comes near, the button leans towards it, a soft light follows the pointer across its face, and when the pointer moves away it drifts back into place. Nothing jumps or runs away; it simply feels ready to be pressed.
Gears, brakes and tyres checked in about 45 minutes. Drop in or book a slot.
How it works
- Watch a field, not the button. The script listens for pointer movement in an invisible zone around the button, so the pull starts before the pointer actually touches it.
- Measure from the edge. It works out how far the pointer is from the nearest edge of the button. Inside the range, the closer the pointer gets, the stronger the pull.
- Follow a share of the distance. The button aims to move about a third of the way towards the pointer, capped at a few pixels so it never wanders off. The label inside moves a little further than the button, which gives a sense of depth.
- Glide, then rest. Each animation frame moves the button part of the way to its target, so movement eases in and out. Once it settles, the loop stops and costs nothing.
- Stay a normal button. It is a real
buttonelement, so clicks, taps, Tab and Enter all work as usual. Touch screens and reduced motion settings skip the pull entirely.
The code
Three short blocks, with the movement handled by two CSS custom properties that the script updates.
<div class="mg" data-lab="magnetic">
<div class="mg-card">
<span class="mg-kicker">Bike shop service</span>
<strong class="mg-title">Spring tune up</strong>
<p class="mg-text">Gears, brakes and tyres checked in about 45 minutes. Drop in or book a slot.</p>
<div class="mg-field">
<button class="mg-btn" type="button">
<span class="mg-glow" aria-hidden="true"></span>
<span class="mg-label">Book a tune up</span>
</button>
</div>
<p class="mg-status" aria-live="polite"></p>
</div>
</div>.mg {
width: min(100%, 380px);
font-family: system-ui, sans-serif;
color: #101a14;
}
.mg-card {
display: grid;
justify-items: center;
gap: 6px;
padding: 26px 22px 18px;
border: 1px solid #c9cec4;
border-radius: 18px;
background: #f2f3ef;
text-align: center;
}
.mg-kicker { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: #46534a; }
.mg-title { font: 700 21px/1.3 system-ui, sans-serif; color: #133621; }
.mg-text { margin: 0; font-size: 15px; line-height: 1.5; color: #46534a; }
/* The field is the invisible zone where the button feels the pointer. */
.mg-field {
display: grid;
place-items: center;
width: 100%;
padding: 30px 0 22px;
}
.mg-btn {
--x: 0px;
--y: 0px;
position: relative;
overflow: hidden;
height: 54px;
padding: 0 30px;
border: 0;
border-radius: 999px;
background: #133621;
color: #f4d96c;
font: 600 16px/1 system-ui, sans-serif;
cursor: pointer;
transform: translate(var(--x), var(--y));
box-shadow: 0 12px 26px -16px rgba(16, 26, 20, .8);
touch-action: manipulation;
}
.mg-btn:focus-visible { outline: 3px solid #f4d96c; outline-offset: 3px; box-shadow: 0 0 0 6px #133621; }
.mg-btn:active { transform: translate(var(--x), var(--y)) scale(.96); }
/* The label travels a little further than the button, which gives depth. */
.mg-label {
position: relative;
display: inline-block;
transform: translate(calc(var(--x) * .4), calc(var(--y) * .4));
}
/* A soft gold light that sits under the pointer. */
.mg-glow {
position: absolute;
left: var(--gx, 50%);
top: var(--gy, 50%);
width: 120px;
height: 120px;
margin: -60px 0 0 -60px;
border-radius: 50%;
background: radial-gradient(circle, rgba(244, 217, 108, .35), rgba(244, 217, 108, 0) 70%);
opacity: 0;
transition: opacity .3s;
pointer-events: none;
}
.mg-btn.is-near .mg-glow { opacity: 1; }
.mg-btn.is-done { background: #2f6b45; }
.mg-status { min-height: 1.4em; margin: 0; font-size: 14px; font-weight: 600; color: #133621; }
@media (prefers-reduced-motion: reduce) {
.mg-btn, .mg-label { transform: none; }
.mg-btn:active { transform: scale(.98); }
}(function () {
var root = document.querySelector('[data-lab="magnetic"]');
if (!root) return;
var field = root.querySelector('.mg-field');
var btn = root.querySelector('.mg-btn');
var label = root.querySelector('.mg-label');
var status = root.querySelector('.mg-status');
var RANGE = 110; // pixels from the button edge where the pull starts
var PULL = 0.35; // share of the pointer distance the button follows
var MAX = 16; // furthest the button may travel, in pixels
var EASE = 0.18; // how quickly it catches up each frame
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
var x = 0, y = 0, tx = 0, ty = 0, raf = 0;
function clamp(v) { return Math.max(-MAX, Math.min(MAX, v)); }
// Glide towards the target, then stop the loop once it has settled.
function frame() {
x += (tx - x) * EASE;
y += (ty - y) * EASE;
if (Math.abs(tx - x) < 0.1 && Math.abs(ty - y) < 0.1) { x = tx; y = ty; raf = 0; }
btn.style.setProperty('--x', x.toFixed(2) + 'px');
btn.style.setProperty('--y', y.toFixed(2) + 'px');
if (raf) raf = requestAnimationFrame(frame);
}
function go() { if (!raf) raf = requestAnimationFrame(frame); }
function release() {
tx = 0; ty = 0;
btn.classList.remove('is-near');
go();
}
field.addEventListener('pointermove', function (e) {
// Touch has no hover, so only mouse and pen feel the pull.
if (e.pointerType === 'touch' || calm.matches) return;
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2 - x, cy = r.top + r.height / 2 - y;
var dx = e.clientX - cx, dy = e.clientY - cy;
// Distance from the button edge, not its centre, so wide buttons behave.
var ox = Math.max(0, Math.abs(dx) - r.width / 2);
var oy = Math.max(0, Math.abs(dy) - r.height / 2);
if (Math.sqrt(ox * ox + oy * oy) > RANGE) { release(); return; }
var near = ox === 0 && oy === 0 ? 1 : 1 - Math.sqrt(ox * ox + oy * oy) / RANGE;
tx = clamp(dx * PULL * near);
ty = clamp(dy * PULL * near);
btn.classList.add('is-near');
btn.style.setProperty('--gx', (e.clientX - r.left) + 'px');
btn.style.setProperty('--gy', (e.clientY - r.top) + 'px');
go();
});
field.addEventListener('pointerleave', release);
btn.addEventListener('click', function () {
btn.classList.add('is-done');
label.textContent = 'Slot requested';
status.textContent = 'This is a demo, so nothing was booked.';
setTimeout(function () {
btn.classList.remove('is-done');
label.textContent = 'Book a tune up';
status.textContent = '';
}, 2600);
});
})();When to use it, and when not to
It suits the single most important action on a page: book a table, request a quote, claim a first visit offer. Used once, it draws the eye to the step you most want people to take, and the follow movement makes the button feel easy to hit rather than harder.
It loses its charm when every button does it, so keep it to one per page. Keep the travel small, too. A button that moves far enough to dodge the pointer turns into a trick, and tricks cost trust, especially around money or cancellations. Never use it on buttons people need to find quickly, like Cancel or Close. Phone visitors will not see the effect at all, since there is no hover on a touch screen, so the button must look good standing still. People who turn on reduced motion in their system settings get a still button, which the stylesheet handles for you.
Make it yours
- Reach:
RANGEis how far from the button edge the pull begins. Around 80 to 140 pixels feels natural. - Strength:
PULLsets how much of the gap the button follows andMAXcaps the travel. KeepMAXunder 20 so it never feels like it is running away. - Feel:
EASEcontrols how quickly it catches up. Lower values feel heavier and calmer; higher values feel snappy.
Frequently asked questions
Does it slow the page down?
No. It only animates while the pointer is near or the button is settling, then the loop stops. It changes a CSS transform, which browsers handle without redrawing the layout.
What happens on phones and tablets?
The pull is skipped for touch, because there is no pointer hovering over the screen. The button still shrinks slightly when pressed, so taps feel responsive.
Can I use it on a link instead of a button?
Yes. Give an a element the same class and markup inside. Links are inline by default and transforms skip inline elements, so add display: inline-flex and align-items: center to .mg-btn.


