Magnetic button
The button leans toward the cursor before you reach it, then springs back when you leave. Costs two CSS variables and a pointermove listener.
How it works
- JS writes only two custom properties,
--mxand--my. Thetransformstays in the stylesheet, which leaves:activefree to ownscale— if JS owned the whole transform, the press feedback would keep getting overwritten mid-drag. - A
.trackingclass zeroes the transform's transition-duration while the pointer is inside the catchment area — the other transitioned properties (the glow, the border) keep theirs, which is why the duration list has three values. Without this the button trails the cursor by the transition duration and feels like it is on elastic. - The release is where the easing lives: 450ms of
cubic-bezier(0.23, 1, 0.32, 1), a strong ease-out that overshoots nothing but decelerates hard. - Gated behind
(pointer: fine). On touch there is no hover — the pointer arrives already pressed, so the effect would only ever fire as a flicker at tap time. getBoundingClientRect()reports the transformed box, so measuring the cursor offset against it measures against a button that has already moved toward the cursor. The two then chase each other to an equilibrium and the actual pull collapses to a fraction ofPULL. Subtracting the currently applied translation recovers the resting centre.- The release has to be handled twice. The obvious path is the next
pointermovelanding outside the catchment area — but if the pointer leaves the document there is no next event, and the button stays stuck at its last offset. The second path is a document-levelmouseoutwhoserelatedTargetisnull, which is the signal that the pointer left the document rather than merely crossing into another element. Notpointerleave: measured here, it does not fire for this case at all.
Numbers
- Catchment radius
- 60px beyond the button box
- Pull strength
- 0.35 × offset from centre
- Release
- 450ms cubic-bezier(0.23, 1, 0.32, 1)
- Press
- scale(0.96)
- Release triggers
- pointermove outside · mouseout w/ null relatedTarget · blur
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a magnetic button in vanilla HTML/CSS/JS. On pointermove, if the cursor is within 60px of the button's bounding box, translate the button by 35% of the cursor's offset from the button centre; outside that range translate back to 0,0. Write the offsets into two CSS custom properties (--mx, --my) and apply them with transform: translate(var(--mx), var(--my)) declared in CSS — do not set the transform from JavaScript. While tracking, set transition-duration to 0ms; on release, transition transform over 450ms cubic-bezier(0.23, 1, 0.32, 1). Add :active { scale: 0.96 }. Measure the cursor offset against the button's RESTING centre: getBoundingClientRect returns the already-transformed box, so subtract the translation currently applied before computing the offset, otherwise the button chases the cursor and the pull collapses. Reset to 0,0 both on a pointermove outside the catchment area and on a document-level mouseout whose relatedTarget is null (the pointer leaving the document fires no further pointermove, so without this the button stays stuck). Only enable the effect when matchMedia('(pointer: fine)') matches and prefers-reduced-motion is not 'reduce'.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Magnetic button</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: grid; place-items: center;
background: #0b0b12;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
color: #fff;
overflow: hidden;
position: relative;
}
/* Faint dot grid + a pool of light under the button. Both are decoration:
they give the pull something to move against, so a 14px offset reads as
movement instead of as a button that was always slightly off-centre. */
body::before {
content: ""; position: absolute; inset: 0;
background-image: radial-gradient(#ffffff14 1px, transparent 1px);
background-size: 22px 22px;
mask-image: radial-gradient(60% 60% at 50% 50%, #000 40%, transparent 100%);
-webkit-mask-image: radial-gradient(60% 60% at 50% 50%, #000 40%, transparent 100%);
}
body::after {
content: ""; position: absolute; left: 50%; top: 50%;
width: 320px; height: 320px; margin: -160px 0 0 -160px;
border-radius: 50%;
background: radial-gradient(circle, #4b6bff33, transparent 68%);
filter: blur(18px);
pointer-events: none;
}
.stack {
position: relative; z-index: 1;
display: grid; justify-items: center; gap: 20px;
}
.eyebrow {
font-size: 10.5px; font-weight: 600;
letter-spacing: 0.22em; text-transform: uppercase;
color: #ffffff5c;
}
.hint { font-size: 12px; color: #ffffff47; letter-spacing: 0.01em; }
.magnet {
/* JS only writes these two variables; the transform stays declarative.
That leaves :active free to own `scale` without the two fighting
over the same property. */
--mx: 0px;
--my: 0px;
position: relative;
padding: 15px 32px;
border: 1px solid #ffffff2e;
border-radius: 99px;
background:
linear-gradient(#ffffff1f, #ffffff08) padding-box,
#14141d;
color: #fff;
font: 600 15px/1 inherit;
letter-spacing: 0.01em;
cursor: pointer;
box-shadow:
0 1px 0 #ffffff21 inset, /* top edge catches the light */
0 10px 30px #00000059,
0 0 0 0 #4b6bff00; /* glow, grown on hover */
transform: translate(var(--mx), var(--my));
/* Only the release should be animated. While tracking the pointer,
a transition adds lag and the button feels like it is on elastic. */
transition:
transform 450ms cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 300ms ease,
border-color 300ms ease;
}
.magnet.tracking { transition-duration: 0ms, 300ms, 300ms; }
.magnet:hover {
border-color: #ffffff4d;
box-shadow:
0 1px 0 #ffffff2e inset,
0 12px 34px #00000066,
0 0 34px 2px #4b6bff4d;
}
.magnet:active { scale: 0.96; }
.magnet:focus-visible { outline: 2px solid #7f9bff; outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) {
.magnet { --mx: 0px !important; --my: 0px !important; }
}
</style>
<div class="stack">
<span class="eyebrow">Magnetic</span>
<button class="magnet">Get in touch</button>
<span class="hint">move your pointer near it</span>
</div>
<script>
const el = document.querySelector(".magnet");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
// Mouse only. A touch screen has no hover: the pointer arrives already pressed.
const fine = matchMedia("(pointer: fine)").matches;
if (!reduce && fine) {
const RADIUS = 60; // catchment area, in px beyond the button's own box
const PULL = 0.35; // travel = 35% of the pointer's offset from centre
// What we have currently applied. Needed to recover the button's resting
// position — see the subtraction below.
let ax = 0, ay = 0;
const set = (x, y, tracking) => {
ax = x; ay = y;
el.classList.toggle("tracking", tracking);
el.style.setProperty("--mx", x.toFixed(2) + "px");
el.style.setProperty("--my", y.toFixed(2) + "px");
};
const release = () => set(0, 0, false);
addEventListener("pointermove", (e) => {
const r = el.getBoundingClientRect();
// getBoundingClientRect reports the *transformed* box, so measuring the
// offset against it means measuring against a button that has already
// moved toward the cursor. Left uncorrected the two chase each other to
// an equilibrium and the pull collapses to a fraction of PULL — invisible
// if you test with one jump of the mouse, unmissable with a real one.
// Subtracting what we applied recovers the resting centre.
const dx = e.clientX - (r.left + r.width / 2 - ax);
const dy = e.clientY - (r.top + r.height / 2 - ay);
const inside =
Math.abs(dx) < r.width / 2 + RADIUS && Math.abs(dy) < r.height / 2 + RADIUS;
if (inside) set(dx * PULL, dy * PULL, true);
else release();
});
// The pull is released by the *next* pointermove landing outside the
// catchment area — but if the pointer leaves the document there is no next
// event, and the button stays stuck wherever it was last pulled to. Easy to
// miss on a full page; guaranteed in a small embed, where the edge is close.
//
// A null relatedTarget is the signal that the pointer left the document
// rather than merely crossing into another element inside it. Note this is
// mouseout, not pointerleave: pointerleave does not fire here.
document.addEventListener("mouseout", (e) => {
if (!e.relatedTarget) release();
});
// Alt-tabbing away mid-pull would otherwise leave it stuck too.
addEventListener("blur", release);
}
</script>Where it breaks
- A global
pointermovelistener fires on every mouse move on the page. With one button that is nothing; with thirty of them on a grid, delegate to a single listener and hit-test, or the main thread will show it. - Pulling the button away from its own box means the pointer can end up hovering the button while sitting over empty space — fine for a big CTA, confusing in a dense toolbar.
- Both of the bugs above were found by driving the thing with a real cursor, not by reading the code. Each one is invisible to the test that moves the mouse in a single jump: one needs continuous movement to show up, the other needs the pointer to actually leave. Without the reset the button sticks in its pulled position whenever the cursor exits the document — which on a full-page layout takes a deliberate flick to the browser chrome, but in an embed like the one above happens every single time. Nothing errors; the button just quietly stops being centred.