ZhuoQi Devzhuoqidev.comRSSllms.txt

← All entries~/design/magnetic-button

Buttons & controlsOriginal

Magnetic Button

The button is pulled toward the approaching mouse, its text following more for depth, then springs back. JS writes only two variables.

html · css · jsMITAdded #button#motion#spring#micro-interaction

Live demoOpen in a new tab ↗

What makes it work

  • JS computes only two numbers: the pointer’s offset from the button’s center, normalized to -1…1 and written into --x and --y. All displacement is in CSS: the button uses translate: calc(var(--x) * var(--pull)) ….
  • Parallax gives depth: the text inside moves 0.6 times the button’s movement, in the same direction but on top of it, so the text looks like it floats a bit higher than the button.
  • Following and bouncing back are two curves: while the pointer is nearby, .near is added and the transition shrinks to 0.18 s ease-out, following tightly; after it leaves, .near is removed and it returns on a 0.9 s spring curve, overshooting a little before it stops.
  • Range of pull: the button is affected within 60px outside it, and the effect gets stronger toward the center; --pull controls the maximum displacement, and round buttons get a larger value.
  • At most one computation per frame: pointermove only records the position, and the actual computation happens in requestAnimationFrame.

How to use it

  1. Copy the three CSS blocks .mag, .mag .t, .mag.near and the script; wrap the text in the button in a <span class="t">.
  2. For a more exaggerated effect, adjust --pull; for a larger range, adjust REACH in the script.
  3. Enable it only under (hover: hover) and (pointer: fine): on touch screens there’s no such thing as “approaching”, and a button running away when a finger presses down just makes it impossible to hit.

Pitfalls

  • When computing distance, use the button’s original position: getBoundingClientRect() returns the displaced box, so the example stores the previous displacement in data-dx/dy and subtracts it back, otherwise the button gets pulled further and further away and jitters.
  • Don’t put too many on one page: every button is measured once per frame; a dozen or so is fine.
  • Under prefers-reduced-motion: reduce, simply turn off the displacement.
$For agents: index.md

Source MIT licensed. Take it.

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>磁吸按钮</title>
    <style>
      :root {
        --paper: #fbf8f1;
        --card: #fffdf8;
        --ink: #1c2230;
        --ink-2: #4a5163;
        --ink-3: #636877;
        --line: #e6dfd0;
        --dot: #d8d0bf;
        --blue: #2f5bd3;
        --on-blue: #fff;
        --spring: linear(0, 0.062, 0.215, 0.411, 0.615, 0.799, 0.95, 1.059, 1.126, 1.158, 1.161, 1.145, 1.116, 1.083, 1.051, 1.023, 1.001, 0.986, 0.977, 0.974, 0.974, 0.978, 0.983, 0.988, 0.994, 0.998, 1.001, 1.003, 1.004, 1.004, 1);
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #14161b;
          --card: #1e2129;
          --ink: #ece7dc;
          --ink-2: #bab6ac;
          --ink-3: #959aa8;
          --line: #31353f;
          --dot: #2c3039;
          --blue: #8eaaff;
          --on-blue: #14161b;
          color-scheme: dark;
        }
      }
      * { box-sizing: border-box; }
      body {
        margin: 0;
        min-height: 100vh;
        display: grid;
        place-items: center;
        align-content: center;
        gap: 18px;
        padding: 32px 16px;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        color: var(--ink);
        font: 16px/1.7 var(--f-sans);
        text-align: center;
      }
      .row { display: flex; flex-wrap: wrap; gap: 48px; justify-content: center; align-items: center; padding: 40px 0; }

      /* ---------- the button ----------
         JS only writes two numbers, --x and --y (how far the pointer is from the centre, -1..1).
         The body follows a little, the label follows more: that difference reads as depth. While
         the pointer moves the transition is short (it tracks); on leave it is a long spring. */
      .mag {
        --x: 0;
        --y: 0;
        --pull: 14px;
        position: relative;
        padding: 16px 30px;
        border: 0;
        border-radius: 999px;
        background: var(--blue);
        color: var(--on-blue);
        font: 600 16px/1 var(--f-sans);
        cursor: pointer;
        translate: calc(var(--x) * var(--pull)) calc(var(--y) * var(--pull));
        transition: translate 0.9s var(--spring), scale 0.45s var(--spring), box-shadow 0.3s;
        box-shadow: 0 10px 24px -10px color-mix(in srgb, var(--blue) 70%, transparent);
      }
      .mag .t {
        display: inline-block;
        translate: calc(var(--x) * var(--pull) * 0.6) calc(var(--y) * var(--pull) * 0.6);
        transition: translate 0.9s var(--spring);
      }
      .mag.near,
      .mag.near .t { transition-duration: 0.18s; transition-timing-function: ease-out; }
      .mag.near { box-shadow: 0 16px 30px -12px color-mix(in srgb, var(--blue) 80%, transparent); }
      .mag:active { scale: 0.94; }
      .mag:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
      .mag.ghost { background: var(--card); color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--ink); --pull: 10px; }
      .mag.round { width: 96px; height: 96px; padding: 0; border-radius: 50%; --pull: 22px; }

      .hint { margin: 0; font-size: 13px; color: var(--ink-3); }
      .hint code { font: 12px var(--f-mono); }
      @media (prefers-reduced-motion: reduce) {
        .mag, .mag .t { translate: none; }
      }
    </style>
  </head>
  <body>
    <div class="row">
      <button class="mag" type="button" data-mag><span class="t">开始使用 →</span></button>
      <button class="mag ghost" type="button" data-mag><span class="t">看看源码</span></button>
      <button class="mag round" type="button" data-mag><span class="t">↗</span></button>
    </div>
    <p class="hint">鼠标靠近,按钮会被「吸」过去,里面的字跟得更多一点;移开时弹回原位。</p>
    <p class="hint">触屏和「减少动态效果」下不动:<code>(hover: hover) and (pointer: fine)</code></p>

    <script>
      // The pull starts this many pixels outside the button and grows towards its centre.
      const REACH = 60;
      const fine = matchMedia("(hover: hover) and (pointer: fine)");
      const still = matchMedia("(prefers-reduced-motion: reduce)");
      const buttons = [...document.querySelectorAll("[data-mag]")];
      let frame = 0;
      let last = null;
      const update = () => {
        frame = 0;
        if (!last) return;
        for (const b of buttons) {
          // The layout box, not the moved one: translate does not change offsetLeft/Top.
          const r = b.getBoundingClientRect();
          const cx = r.left + r.width / 2 - (parseFloat(b.dataset.dx) || 0);
          const cy = r.top + r.height / 2 - (parseFloat(b.dataset.dy) || 0);
          const dx = last.clientX - cx;
          const dy = last.clientY - cy;
          const near = Math.abs(dx) < r.width / 2 + REACH && Math.abs(dy) < r.height / 2 + REACH;
          const x = near ? Math.max(-1, Math.min(1, dx / (r.width / 2 + REACH))) : 0;
          const y = near ? Math.max(-1, Math.min(1, dy / (r.height / 2 + REACH))) : 0;
          const pull = parseFloat(getComputedStyle(b).getPropertyValue("--pull")) || 0;
          b.dataset.dx = String(x * pull);
          b.dataset.dy = String(y * pull);
          b.classList.toggle("near", near);
          b.style.setProperty("--x", x.toFixed(3));
          b.style.setProperty("--y", y.toFixed(3));
        }
      };
      addEventListener(
        "pointermove",
        (e) => {
          if (!fine.matches || still.matches || e.pointerType !== "mouse") return;
          last = e;
          frame ||= requestAnimationFrame(update);
        },
        { passive: true },
      );
      // Leaving the window lets every button spring home.
      document.documentElement.addEventListener("pointerleave", () => {
        last = { clientX: -1e4, clientY: -1e4 };
        frame ||= requestAnimationFrame(update);
      });
    </script>
  </body>
</html>

Say hi on WeChat

Liu ZhuoQi's WeChat QR code

Scan it in WeChat, or long-press it on your phone

Mention why you're reaching out, or I might miss it.