Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Magnetic buttons" for my site using the Enrichr Library element at https://enrichr.dev/elements/magnetic-buttons.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/magnetic-buttons.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- accent: #e6ff55
- strength: 0.4
- radius: 80
- spring: 0.12
- labelShift: 1.8
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/magnetic-buttons.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-mgb">
<div class="ef-mgb__wrap"><div class="ef-mgb__row"></div></div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-mgb { width: 100%; }
.ef-mgb__wrap { box-sizing: border-box; width: 100%; min-height: 18rem; padding: 1.5rem; border-radius: 1rem; display: flex; align-items: center; justify-content: center; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-mgb__row { display: flex; flex-wrap: wrap; gap: 2.2rem; align-items: center; justify-content: center; padding: 1rem; }
.ef-mgb__btn { --x: 0px; --y: 0px; --lx: 0px; --ly: 0px; --a: 0; position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 8rem; padding: 1rem 1.7rem; border: 1.5px solid var(--ef-acc, #e6ff55); border-radius: 999px; background: transparent; color: var(--ef-acc, #e6ff55); font: inherit; font-size: 0.95rem; font-weight: 700; letter-spacing: 0.04em; cursor: pointer; transform: translate3d(var(--x), var(--y), 0); will-change: transform; outline: none; overflow: hidden; transition: color 250ms, box-shadow 250ms; }
.ef-mgb__btn::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--ef-acc, #e6ff55); opacity: var(--a); transform: translate3d(calc(var(--x) * -0.4), calc(var(--y) * -0.4), 0); transition: opacity 250ms; }
.ef-mgb__btn.is-near { color: #0c1e18; }
.ef-mgb__lbl { position: relative; display: block; transform: translate3d(var(--lx), var(--ly), 0); pointer-events: none; }
.ef-mgb__btn:focus-visible { box-shadow: 0 0 0 3px #0c1e18, 0 0 0 5px var(--ef-acc, #e6ff55); }
.ef-mgb__btn:focus-visible::before { opacity: 0.18; }
@media (prefers-reduced-motion: reduce) { .ef-mgb__btn { transform: none; } .ef-mgb__lbl { transform: none; } .ef-mgb__btn::before { transform: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Magnetic buttons: buttons that pull toward the pointer on a spring, with the inner label drifting further than the shell (parallax).
// A single rAF loop integrates a spring per button and only runs while something is moving. Mouse and pen only; keyboard focus
// shows a ring, and reduced motion keeps the colour fill but no movement. Pointer tracking is on the wrapper, so it works in a clipped box.
// mount(root, { background, accent, strength (0.1-0.8), radius (px), spring (0.04-0.4), labelShift (1-3) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-mgb__wrap");
var row = root.querySelector(".ef-mgb__row");
var opts = {}, items = [], raf = 0, ptr = null, offs = [];
var NAMES = ["Get started", "Explore", "Say hello"];
function build() {
row.textContent = "";
items = [];
NAMES.forEach(function (name) {
var b = document.createElement("button");
b.type = "button"; b.className = "ef-mgb__btn";
var l = document.createElement("span"); l.className = "ef-mgb__lbl"; l.textContent = name;
b.appendChild(l); row.appendChild(b);
items.push({ el: b, x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0 });
});
}
function frame() {
raf = 0;
var busy = false, k = opts.spring, damp = 0.78;
items.forEach(function (it) {
it.vx = (it.vx + (it.tx - it.x) * k) * damp; it.vy = (it.vy + (it.ty - it.y) * k) * damp;
it.x += it.vx; it.y += it.vy;
var rest = Math.abs(it.tx - it.x) < 0.05 && Math.abs(it.ty - it.y) < 0.05 && Math.abs(it.vx) < 0.05 && Math.abs(it.vy) < 0.05;
if (rest) { it.x = it.tx; it.y = it.ty; it.vx = it.vy = 0; } else busy = true;
var s = it.el.style;
s.setProperty("--x", it.x.toFixed(2) + "px"); s.setProperty("--y", it.y.toFixed(2) + "px");
s.setProperty("--lx", (it.x * (opts.labelShift - 1)).toFixed(2) + "px"); s.setProperty("--ly", (it.y * (opts.labelShift - 1)).toFixed(2) + "px");
});
if (busy) raf = requestAnimationFrame(frame);
}
function kick() { if (!raf) raf = requestAnimationFrame(frame); }
function aim() {
items.forEach(function (it) {
var near = false, tx = 0, ty = 0;
if (ptr) {
var r = it.el.getBoundingClientRect();
var cx = r.left + r.width / 2 - it.x, cy = r.top + r.height / 2 - it.y;
var dx = ptr.x - cx, dy = ptr.y - cy;
if (Math.abs(dx) < r.width / 2 + opts.radius && Math.abs(dy) < r.height / 2 + opts.radius) { near = true; tx = dx * opts.strength; ty = dy * opts.strength; }
}
if (reduce) { tx = 0; ty = 0; }
it.tx = tx; it.ty = ty;
it.el.style.setProperty("--a", near ? "1" : "0");
it.el.classList.toggle("is-near", near);
});
kick();
}
function mv(e) { if (e.pointerType === "touch") return; ptr = { x: e.clientX, y: e.clientY }; aim(); }
function lv() { ptr = null; aim(); }
wrap.addEventListener("pointermove", mv);
wrap.addEventListener("pointerleave", lv);
offs.push(function () { wrap.removeEventListener("pointermove", mv); wrap.removeEventListener("pointerleave", lv); });
function update(next) {
var first = !items.length;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", strength: 0.4, radius: 80, spring: 0.12, labelShift: 1.8 }, opts, next || {});
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
if (first) build();
}
function destroy() {
offs.forEach(function (f) { f(); }); offs = [];
if (raf) cancelAnimationFrame(raf); raf = 0;
}
update(o);
return { update: update, destroy: destroy };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","strength":0.4,"radius":80,"spring":0.12,"labelShift":1.8});How it works
A pointer listener on the wrapper stores the cursor position. For each button the script measures its centre and, if the cursor is within the magnet radius, sets a target offset equal to the distance times the strength; outside it, the target is zero. One requestAnimationFrame loop then integrates a damped spring per button toward its target and writes --x and --y, plus --lx and --ly for the label, which moves further than the shell. The stylesheet applies them as translate3d and fades in a filled layer when the pointer is near. The loop stops itself once every button is at rest.
Accessibility and performance
These are real buttons with a visible focus ring, so keyboard users get the same affordance without motion. Movement is mouse and pen only; touch and prefers-reduced-motion get no displacement. The pull is small enough that hit targets stay stable, and the label keeps strong contrast against both states.
One rAF loop runs only while a button is moving. Each frame writes four custom properties per button and only transforms and opacity change. Layout is read once per pointer move per button, so keep the button count small.
| Section | Band |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Magnetic Buttons by Manoela Ilic on Codrops. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Why a spring instead of a CSS transition?
A spring keeps velocity, so the button overshoots slightly and settles, which feels physical. Transitions restart from zero on every pointer move.
Can I use it on a link or icon button?
Yes. Give any element the same structure with an inner label span and the same CSS variables.
Does it work inside a clipped container?
Yes. Tracking is on the wrapper, and the buttons are translated only a few pixels, so overflow hidden is fine.