磁吸按鈕
指针还没碰到,按钮就先朝它偏过来;离开时弹回原位。成本是两个 CSS 变量加一个 pointermove 监听。
怎麼做到的
- JS writes only two custom properties,
--mxand--my. Thetransformstays in the stylesheet, which leaves:activefree to ownscale— if JS owned the whole transform, the press feedback would keep getting overwritten mid-drag. - A
.trackingclass setstransition-duration: 0mswhile the pointer is inside the catchment area. Without it the button trails the cursor by the transition duration and feels like it is on elastic. - The release is where the easing lives: 450ms of
cubic-bezier(0.23, 1, 0.32, 1), a strong ease-out that overshoots nothing but decelerates hard. - Gated behind
(pointer: fine). On touch there is no hover — the pointer arrives already pressed, so the effect would only ever fire as a flicker at tap time. getBoundingClientRect()reports the transformed box, so measuring the cursor offset against it measures against a button that has already moved toward the cursor. The two then chase each other to an equilibrium and the actual pull collapses to a fraction ofPULL. Subtracting the currently applied translation recovers the resting centre.- The release has to be handled twice. The obvious path is the next
pointermovelanding outside the catchment area — but if the pointer leaves the document there is no next event, and the button stays stuck at its last offset. The second path is a document-levelmouseoutwhoserelatedTargetisnull, which is the signal that the pointer left the document rather than merely crossing into another element. Notpointerleave: measured here, it does not fire for this case at all.
參數
- 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 打開就能用,不需要別的任何東西。
<!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: #fff;
font: 500 15px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
color: #1d1d1f;
}
.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;
padding: 15px 30px;
border: 0; border-radius: 99px;
background: #1d1d1f; color: #f5f5f7;
font: inherit; cursor: pointer;
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);
}
.magnet.tracking { transition-duration: 0ms; }
.magnet:active { scale: 0.96; }
@media (prefers-reduced-motion: reduce) {
.magnet { --mx: 0px !important; --my: 0px !important; }
}
</style>
<button class="magnet">Hover me</button>
<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 — so if the pointer leaves
// the document altogether there is no next event, and the button stays
// stuck wherever it was last pulled to. Easy to miss on a full page, and
// guaranteed to happen in a small embed, where the edge is right there.
// 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>會翻車的地方
- A global
pointermovelistener fires on every mouse move on the page. With one button that is nothing; with thirty of them on a grid, delegate to a single listener and hit-test, or the main thread will show it. - Pulling the button away from its own box means the pointer can end up hovering the button while sitting over empty space — fine for a big CTA, confusing in a dense toolbar.
- Both of the bugs above were found by driving the thing with a real cursor, not by reading the code. Each one is invisible to the test that moves the mouse in a single jump: one needs continuous movement to show up, the other needs the pointer to actually leave. Without the reset the button sticks in its pulled position whenever the cursor exits the document — which on a full-page layout takes a deliberate flick to the browser chrome, but in an embed like the one above happens every single time. Nothing errors; the button just quietly stops being centred.