Magnetic button: a call to action that leans towards the pointer

Bring the mouse near and the button leans in to meet it, then glides home when you move away. One small touch of life for the action that matters most.

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.

Bike shop service Spring tune up

Gears, brakes and tyres checked in about 45 minutes. Drop in or book a slot.

Live demo. It runs on exactly the code further down this page.

How it works

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Stay a normal button. It is a real button element, 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.

HTML
<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>
CSS
.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); }
}
JavaScript
(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: RANGE is how far from the button edge the pull begins. Around 80 to 140 pixels feels natural.
  • Strength: PULL sets how much of the gap the button follows and MAX caps the travel. Keep MAX under 20 so it never feels like it is running away.
  • Feel: EASE controls 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.

What should your website do?

Describe a feature you wish your site had. If it is useful to other owners too, we build it here as a free Lab post with a live demo and code.

We never publish your name, email or website. Privacy

More from the Website Lab

All experiments
Buttons

Hold to confirm button in plain JavaScript

A press and hold button for risky actions like delete, with a filling progress ring, keyboard support and live demo code you can copy.

3 min read