网页设计特效

磁吸按钮

指针还没碰到,按钮就先朝它偏过来;离开时弹回原位。成本是两个 CSS 变量加一个 pointermove 监听。

怎么做到的

参数

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

提示词

把这段丢给任意一个写代码的模型,就能从零长出这个效果。它把每个数值都写死了 ——「丝滑」「现代感」这种词每次生成出来的都不一样。

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'.

源码

整个文件。上面那个样板跑的就是它 —— 存成 .html 打开就能用,不需要别的任何东西。

magnetic-button.html
<!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>

会翻车的地方