Copied!
Hero

Wiggle magnet button

A pill button that never stops wiggling, gets pulled toward the pointer, and snaps home with an elastic wobble when the pointer leaves.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

HeroElementVanilla JS + CSS

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 "Wiggle magnet button" for my site using the Enrichr Library element at https://enrichr.dev/elements/wiggle-magnet-button.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/wiggle-magnet-button.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:
- label: Get started
- strength: 0.5
- wiggle: 12
- period: 1.2
- colorA: #10b981
- colorB: #34d399
- background: #0f172a

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/wiggle-magnet-button.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-wmb"><div class="ef-wmb__zone"><span class="ef-wmb__mag"><button type="button" class="ef-wmb__btn"><span class="ef-wmb__label"></span></button></span></div></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-wmb__zone { position: relative; min-height: 16rem; display: flex; align-items: center; justify-content: center; padding: 2rem; border-radius: 1rem; border: 1px dashed rgba(255, 255, 255, 0.12); overflow: hidden; }
.ef-wmb__mag { display: inline-block; will-change: transform; }
.ef-wmb__btn { position: relative; display: inline-flex; align-items: center; justify-content: center; padding: 0.95rem 1.9rem; border: 0; border-radius: 9999px; font: 600 1rem/1 inherit; font-family: inherit; color: var(--ef-ink, #fff); background: linear-gradient(135deg, var(--ef-a, #10b981), var(--ef-b, #34d399)); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; animation: ef-wmb-wiggle var(--ef-period, 1.2s) cubic-bezier(0.37, 0, 0.63, 1) infinite alternate; }
.ef-wmb__btn:focus-visible { outline: 2px solid var(--ef-b, #34d399); outline-offset: 4px; }
.ef-wmb__label { display: inline-block; will-change: transform; }
@keyframes ef-wmb-wiggle { from { transform: rotate(0deg); } to { transform: rotate(var(--ef-wiggle, 12deg)); } }
@media (prefers-reduced-motion: reduce) { .ef-wmb__btn { animation: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Wiggle magnet button: a pill button that never stops wiggling, gets pulled toward the pointer while it is near,
// and snaps home with an elastic wobble when the pointer leaves. The wiggle (rotation) and the pull (translation)
// live on different elements, so neither one interrupts the other.
// mount(root, { label, strength (0-1), wiggle (degrees), period (seconds per swing), colorA, colorB, background })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
  var zone = root.querySelector(".ef-wmb__zone"), mag = root.querySelector(".ef-wmb__mag");
  var btn = root.querySelector(".ef-wmb__btn"), label = root.querySelector(".ef-wmb__label");
  var opts = {}, raf = 0, last = 0, inside = false, visible = true, io = null;
  var x = 0, y = 0, vx = 0, vy = 0, tx = 0, ty = 0;

  function paint() {
    mag.style.transform = "translate3d(" + x.toFixed(2) + "px," + y.toFixed(2) + "px,0)";
    label.style.transform = "translate3d(" + (x * 0.15).toFixed(2) + "px," + (y * 0.15).toFixed(2) + "px,0)";
  }
  function frame(t) {
    raf = 0;
    if (!visible) return;
    var dt = Math.min(0.033, (t - last) / 1000 || 0.016);
    last = t;
    // Inside: a critically damped spring (smooth follow). Leaving: an underdamped spring (elastic wobble home).
    var k = inside ? 200 : 500, c = inside ? 28 : 20;
    vx += (k * (tx - x) - c * vx) * dt;
    vy += (k * (ty - y) - c * vy) * dt;
    x += vx * dt;
    y += vy * dt;
    paint();
    if (Math.abs(tx - x) + Math.abs(ty - y) + Math.abs(vx) + Math.abs(vy) > 0.05) raf = requestAnimationFrame(frame);
    else { x = tx; y = ty; vx = vy = 0; paint(); }
  }
  function wake() { if (!raf && visible) { last = performance.now(); raf = requestAnimationFrame(frame); } }
  function move(e) {
    var r = zone.getBoundingClientRect();
    inside = true;
    tx = (e.clientX - r.left - r.width / 2) * opts.strength;
    ty = (e.clientY - r.top - r.height / 2) * opts.strength;
    wake();
  }
  function leave() { inside = false; tx = ty = 0; wake(); }
  function update(next) {
    opts = Object.assign({ label: "Get started", strength: 0.5, wiggle: 12, period: 1.2, colorA: "#10b981", colorB: "#34d399", background: "#0f172a" }, opts, next || {});
    opts.strength = Number(opts.strength);
    label.textContent = opts.label;
    zone.style.background = opts.background;
    btn.style.setProperty("--ef-a", opts.colorA);
    btn.style.setProperty("--ef-b", opts.colorB);
    btn.style.setProperty("--ef-wiggle", Number(opts.wiggle) + "deg");
    btn.style.setProperty("--ef-period", Number(opts.period) + "s");
    // Restart the CSS wiggle so a new angle or speed takes effect at once.
    btn.style.animation = "none";
    void btn.offsetWidth;
    btn.style.animation = "";
  }
  update(o);
  if (fine && !reduce) {
    zone.addEventListener("pointermove", move);
    zone.addEventListener("pointerleave", leave);
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) wake(); }); io.observe(zone); }
  }
  return {
    update: update,
    destroy: function () {
      cancelAnimationFrame(raf); raf = 0;
      if (io) io.disconnect();
      zone.removeEventListener("pointermove", move); zone.removeEventListener("pointerleave", leave);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"label":"Get started","strength":0.5,"wiggle":12,"period":1.2,"colorA":"#10b981","colorB":"#34d399","background":"#0f172a"});

How it works

Two motions run on two different elements so they never fight. The button itself rocks between 0 degrees and the wiggle angle with a CSS animation (ease-in-out, alternating forever). An outer wrapper handles the pull: while the pointer is inside the area, its target is the pointer's offset from the centre multiplied by the pull strength, and a critically damped spring moves the wrapper there smoothly. When the pointer leaves, the target becomes zero and the spring switches to a bouncy, underdamped setting, so the button overshoots and wobbles home. The label moves a further 30 percent of the pull for a little depth. This is the same idea as GSAP's overwrite: "auto", where a new position tween only replaces the old position tween and leaves the rotation running, done here without any library.

Accessibility and performance

It is a real button element with a visible focus ring, and the label is plain text. The pull only runs for a mouse or trackpad (fine pointer with hover); touch screens get the wiggle alone. With reduced motion there is no wiggle and no pull, just a still button. Keep the wiggle angle modest so the label stays easy to read.

The wiggle is a CSS animation on transform, which the browser runs on the compositor. The spring loop runs only while the wrapper is moving and stops when it settles, and it pauses when the area is off screen. Each frame writes two transforms.

SectionHero
TechniquesPointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byGSAP overwrite showcase by Homeless Romantic Creative on CodePen. 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.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

Can I drop it into an existing hero?

Yes. Remove the area background and dashed border and give the area the size you want the magnet to reach. The pull is measured from the area's centre.

How do I make the snap-back more or less bouncy?

The leave spring uses stiffness 500 and damping 20. Raise the damping (try 30) for less wobble or lower it (try 14) for more.

Why not use GSAP?

Two springs and one CSS animation are all this needs, so it ships with no dependencies. If your site already loads GSAP, the same effect is a rotation tween plus x and y tweens with overwrite set to auto.