Button loading spinnerWeb · App
Target
Implement the "Magnetic button" effect in vanilla HTML/CSS/JS. Context: The button drifts toward the pointer while hovered and springs back to centre when the pointer leaves. Trigger: the pointer hovers the element (fall back to focus on touch devices). Timeline: time-based (duration + easing). Motion tokens (normal): - translate (button): 0,0 → up to 35% of pointer offset, max 24px - translate (label): 0,0 → an extra 15% of pointer offset - duration: follow ≈ 120ms lerp · release 600ms - easing: follow: linear interpolation 0.18/frame · release: elastic.out(1, 0.4) Motion tokens (prefers-reduced-motion): - translate: 0 → 0 - duration: 0 - note: disabled entirely; keep the hover colour change only Technique: plain JavaScript (requestAnimationFrame / class toggles), no animation library required; GSAP (ScrollTrigger where relevant) is acceptable as an alternative. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - pointer-only; never active on touch devices (check hover: hover media query) - clamp displacement so the button never leaves its hit area by more than 24px
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-magnetic-button" data-fx-root> <div class="fx-zone"><button type="button" class="fx-btn"><span class="fx-label">Get started</span></button></div> </div>
demo.css
.fx-magnetic-button { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.fx-magnetic-button .fx-zone { display: flex; align-items: center; justify-content: center; width: 60%; height: 70%; border-radius: 16px; }
.fx-magnetic-button .fx-btn { height: 48px; padding: 0 26px; border: 0; border-radius: 999px; background: var(--fx-ink, #18181b); color: #fff; font: 500 15px/1 Inter, system-ui, sans-serif; cursor: pointer; will-change: transform; transition: background-color 150ms ease; }
.fx-magnetic-button .fx-btn:hover { background: var(--fx-accent, #4f46e5); }
.fx-magnetic-button .fx-label { display: inline-block; will-change: transform; }
demo.js
export default function mount(root) {
const zone = root.querySelector('.fx-zone'), btn = root.querySelector('.fx-btn'), label = root.querySelector('.fx-label');
const enabled = matchMedia('(hover: hover)').matches && !matchMedia('(prefers-reduced-motion: reduce)').matches;
let target = { x: 0, y: 0 }, cur = { x: 0, y: 0 }, raf = 0, releasing = false, t0 = 0, from = { x: 0, y: 0 };
const clamp = (v) => Math.max(-24, Math.min(24, v));
const elastic = (t) => t === 1 ? 1 : 1 - Math.pow(2, -10 * t) * Math.cos((t * 10 - 0.75) * (2 * Math.PI) / 3 * 0.75 / 0.4 * 0.4); // ≈ elastic.out(1, .4)
const apply = () => { btn.style.transform = `translate(${cur.x}px, ${cur.y}px)`; label.style.transform = `translate(${cur.x * 0.43}px, ${cur.y * 0.43}px)`; };
const tick = (now) => {
if (releasing) { const p = Math.min(1, (now - t0) / 600); const e = elastic(p); cur = { x: from.x * (1 - e), y: from.y * (1 - e) }; apply(); if (p < 1) raf = requestAnimationFrame(tick); else { releasing = false; btn.style.transform = label.style.transform = ''; } return; }
cur.x += (target.x - cur.x) * 0.18; cur.y += (target.y - cur.y) * 0.18; apply();
if (Math.abs(target.x - cur.x) > 0.1 || Math.abs(target.y - cur.y) > 0.1) raf = requestAnimationFrame(tick);
};
const onMove = (e) => { if (!enabled) return; const r = btn.getBoundingClientRect(); releasing = false; target = { x: clamp((e.clientX - (r.left + r.width / 2)) * 0.35), y: clamp((e.clientY - (r.top + r.height / 2)) * 0.35) }; cancelAnimationFrame(raf); raf = requestAnimationFrame(tick); };
const onLeave = () => { if (!enabled) return; releasing = true; from = { ...cur }; t0 = performance.now(); cancelAnimationFrame(raf); raf = requestAnimationFrame(tick); };
zone.addEventListener('pointermove', onMove); zone.addEventListener('pointerleave', onLeave);
let demo;
return {
play() { // scripted pointer path for the gallery
clearTimeout(demo); if (!enabled) return; const r = btn.getBoundingClientRect(); const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const pts = [[40, 10], [50, -14], [-30, 18], [-45, -8]]; let i = 0;
const step = () => { if (i < pts.length) { onMove({ clientX: cx + pts[i][0], clientY: cy + pts[i][1] }); i++; demo = setTimeout(step, 260); } else onLeave(); };
step();
},
stop() { clearTimeout(demo); cancelAnimationFrame(raf); releasing = false; cur = target = { x: 0, y: 0 }; btn.style.transform = label.style.transform = ''; },
};
}
demo.gsap.js
export default function mount(root) {
const zone = root.querySelector('.fx-zone'), btn = root.querySelector('.fx-btn'), label = root.querySelector('.fx-label');
const enabled = matchMedia('(hover: hover)').matches && !matchMedia('(prefers-reduced-motion: reduce)').matches;
let xTo, yTo, lxTo, lyTo, demo;
const clamp = (v) => Math.max(-24, Math.min(24, v));
const onMove = (e) => { if (!enabled || !xTo) return; const r = btn.getBoundingClientRect(); const x = clamp((e.clientX - (r.left + r.width / 2)) * 0.35), y = clamp((e.clientY - (r.top + r.height / 2)) * 0.35); xTo(x); yTo(y); lxTo(x * 0.43); lyTo(y * 0.43); };
const onLeave = () => { if (!enabled || !window.gsap) return; gsap.to([btn, label], { x: 0, y: 0, duration: 0.6, ease: 'elastic.out(1, 0.4)', overwrite: true }); };
zone.addEventListener('pointermove', onMove); zone.addEventListener('pointerleave', onLeave);
return {
play() {
const { gsap } = window; if (!gsap) { root.dataset.fxMode = 'gsap-missing'; return; }
root.dataset.fxMode = 'gsap';
xTo = gsap.quickTo(btn, 'x', { duration: 0.3, ease: 'power3' }); yTo = gsap.quickTo(btn, 'y', { duration: 0.3, ease: 'power3' });
lxTo = gsap.quickTo(label, 'x', { duration: 0.3, ease: 'power3' }); lyTo = gsap.quickTo(label, 'y', { duration: 0.3, ease: 'power3' });
clearTimeout(demo); if (!enabled) return; const r = btn.getBoundingClientRect(); const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const pts = [[40, 10], [50, -14], [-30, 18], [-45, -8]]; let i = 0;
const step = () => { if (i < pts.length) { onMove({ clientX: cx + pts[i][0], clientY: cy + pts[i][1] }); i++; demo = setTimeout(step, 260); } else onLeave(); };
step();
},
stop() { clearTimeout(demo); window.gsap?.killTweensOf([btn, label]); window.gsap?.set([btn, label], { clearProps: 'transform' }); },
};
}
Inspired by gsap.com — the quickTo() magnetic button from GSAP demos. The preview is an original re-creation; the effect name belongs to its author.
Hover-only by definition, so it is gated on `(hover: hover)` and skipped under reduced motion. The GSAP version is three lines thanks to `quickTo()`; the vanilla one needs its own lerp loop plus a hand-rolled elastic release.
Added 2026-11-09 · Report an issue