Magnetic Cursor
A custom easing cursor (dot plus lagging ring) paired with magnetic buttons and links that pull toward the pointer inside a radius, snap back on leave, and grow their label on hover. Multiple targets, live tunable strength and radius, touch-hidden and reduced-motion aware.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(120% 80% at 15% -10%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(90% 70% at 100% 0%, rgba(34, 211, 238, 0.10), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
}
/* When the custom cursor is active we hide the native one on interactive bits. */
body.cursor-active,
body.cursor-active a,
body.cursor-active button,
body.cursor-active input[type="range"] {
cursor: none;
}
/* ---------- Layout ---------- */
.page {
max-width: 980px;
margin: 0 auto;
padding: clamp(24px, 5vw, 56px) clamp(18px, 4vw, 40px) 48px;
display: grid;
grid-template-columns: 1fr 300px;
grid-template-areas:
"head head"
"stage panel"
"foot foot";
gap: clamp(20px, 3vw, 32px);
}
.head { grid-area: head; }
.stage { grid-area: stage; }
.panel { grid-area: panel; }
.foot { grid-area: foot; }
/* ---------- Header ---------- */
.eyebrow {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-2);
}
h1 {
margin: 0 0 12px;
font-size: clamp(28px, 5vw, 44px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.05;
}
.lede {
margin: 0;
max-width: 62ch;
color: var(--muted);
font-size: 15px;
}
/* ---------- Stage ---------- */
.stage {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent),
var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: clamp(24px, 4vw, 44px);
display: flex;
flex-direction: column;
gap: 22px;
min-height: 320px;
justify-content: center;
}
.stage__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: clamp(16px, 3vw, 28px);
align-items: center;
justify-items: center;
}
.stage__hint {
margin: 0;
text-align: center;
font-size: 12.5px;
color: var(--muted);
}
/* ---------- Magnetic targets ---------- */
.magnet {
position: relative;
border: 0;
margin: 0;
font-family: inherit;
text-decoration: none;
color: var(--ink);
will-change: transform;
display: inline-flex;
align-items: center;
justify-content: center;
transition: box-shadow 0.25s ease, background-color 0.25s ease, border-color 0.25s ease;
}
.magnet__label {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
will-change: transform;
pointer-events: none;
}
.magnet:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
.btn-primary {
padding: 14px 26px;
border-radius: 999px;
font-size: 15px;
font-weight: 600;
background: linear-gradient(180deg, var(--accent), #7c3aed);
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.28);
}
.btn-primary:hover { box-shadow: 0 12px 32px rgba(139, 92, 246, 0.42); }
.btn-ghost {
padding: 13px 24px;
border-radius: 999px;
font-size: 15px;
font-weight: 600;
background: transparent;
border: 1px solid var(--line-2);
}
.btn-ghost:hover {
background: var(--surface-2);
border-color: var(--accent);
}
.tile {
flex-direction: column;
gap: 10px;
width: 108px;
height: 108px;
border-radius: var(--r-lg);
background: var(--surface-2);
border: 1px solid var(--line);
}
.tile:hover { border-color: var(--accent-2); }
.tile__glyph { font-size: 26px; color: var(--accent-2); }
.tile__cap {
font-size: 13px;
font-weight: 600;
color: var(--muted);
pointer-events: none;
}
.chip {
padding: 10px 18px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
background: rgba(34, 211, 238, 0.12);
color: var(--accent-2);
border: 1px solid rgba(34, 211, 238, 0.28);
}
.chip:hover { background: rgba(34, 211, 238, 0.2); }
.icon-round {
width: 60px;
height: 60px;
border-radius: 999px;
font-size: 18px;
background: var(--surface-2);
border: 1px solid var(--line-2);
color: var(--ok);
}
.icon-round:hover { border-color: var(--ok); }
.link-inline {
font-size: 15px;
font-weight: 600;
color: var(--ink);
padding: 6px 4px;
border-bottom: 2px solid transparent;
border-radius: 2px;
}
.link-inline .magnet__label { text-decoration: underline; text-underline-offset: 4px; text-decoration-color: var(--accent); }
.link-inline:hover { color: var(--accent); }
/* ---------- Custom cursor ---------- */
.cursor {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
pointer-events: none;
display: none;
}
body.cursor-active .cursor { display: block; }
.cursor__dot,
.cursor__ring {
position: fixed;
top: 0;
left: 0;
border-radius: 999px;
transform: translate3d(-50%, -50%, 0);
pointer-events: none;
}
.cursor__dot {
width: 7px;
height: 7px;
background: var(--accent-2);
box-shadow: 0 0 10px rgba(34, 211, 238, 0.7);
}
.cursor__ring {
width: 34px;
height: 34px;
border: 1.5px solid rgba(233, 234, 240, 0.55);
transition: width 0.22s ease, height 0.22s ease, border-color 0.22s ease, background-color 0.22s ease;
}
.cursor.is-hovering .cursor__ring {
width: 56px;
height: 56px;
border-color: var(--accent);
background: rgba(139, 92, 246, 0.12);
}
.cursor.is-down .cursor__ring {
width: 26px;
height: 26px;
}
/* ---------- Panel + controls ---------- */
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
align-self: start;
}
.panel__row--toggle { padding-bottom: 4px; }
.switch {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch__track {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--line-2);
transition: background-color 0.2s ease, border-color 0.2s ease;
flex: none;
}
.switch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--muted);
transition: transform 0.2s ease, background-color 0.2s ease;
}
.switch input:checked + .switch__track {
background: rgba(139, 92, 246, 0.3);
border-color: var(--accent);
}
.switch input:checked + .switch__track .switch__thumb {
transform: translateX(18px);
background: var(--accent);
}
.switch input:focus-visible + .switch__track {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.control { display: flex; flex-direction: column; gap: 8px; }
.control__head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
}
.control__head label { color: var(--muted); font-weight: 500; }
.control__head output {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12.5px;
color: var(--ink);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 999px;
background: var(--surface-2);
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 999px;
background: var(--accent);
border: 2px solid var(--bg);
box-shadow: 0 0 0 1px var(--accent);
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border: 2px solid var(--bg);
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
cursor: pointer;
}
input[type="range"]:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 4px;
}
.readout {
margin: 4px 0 0;
padding-top: 16px;
border-top: 1px solid var(--line);
display: grid;
gap: 8px;
}
.readout > div {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.readout dt {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.readout dd {
margin: 0;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12.5px;
color: var(--ink);
text-align: right;
}
/* ---------- Footer ---------- */
.note {
margin: 0;
font-size: 12.5px;
color: var(--muted);
}
.note strong { color: var(--warn); font-weight: 600; }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
.page {
grid-template-columns: 1fr;
grid-template-areas:
"head"
"stage"
"panel"
"foot";
}
.panel { align-self: stretch; }
}
@media (max-width: 520px) {
.stage__grid { gap: 16px; }
.btn-primary, .btn-ghost { padding: 12px 20px; }
}
/* Reduced motion: keep hover states, drop the animated lag styling nuance. */
@media (prefers-reduced-motion: reduce) {
.cursor__ring { transition: none; }
.magnet { transition: box-shadow 0.15s ease, background-color 0.15s ease; }
}(function () {
"use strict";
var doc = document;
var body = doc.body;
// ---- Feature detection ---------------------------------------------------
var coarse =
window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
var hasHover =
window.matchMedia && window.matchMedia("(hover: hover)").matches;
var reduceMotionMQ =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)");
var reduceMotion = reduceMotionMQ ? reduceMotionMQ.matches : false;
// Touch / no-hover devices: never show the custom cursor.
var touchLike = coarse || !hasHover;
// ---- Elements ------------------------------------------------------------
var cursor = doc.getElementById("cursor");
var ring = doc.getElementById("cursorRing");
var dot = doc.getElementById("cursorDot");
var page = doc.getElementById("page");
var enabledInput = doc.getElementById("enabled");
var lagInput = doc.getElementById("lag");
var strengthInput = doc.getElementById("strength");
var radiusInput = doc.getElementById("radius");
var lagOut = doc.getElementById("lagOut");
var strengthOut = doc.getElementById("strengthOut");
var radiusOut = doc.getElementById("radiusOut");
var rPointer = doc.getElementById("rPointer");
var rCaptured = doc.getElementById("rCaptured");
var rStatus = doc.getElementById("rStatus");
var modeNote = doc.getElementById("modeNote");
// Guard against the intentional odd id above so the demo never breaks.
if (!lagOut) lagOut = doc.querySelector('output[for="lag"]');
// ---- State ---------------------------------------------------------------
var settings = {
lag: parseFloat(lagInput.value), // ring easing factor
strength: parseFloat(strengthInput.value), // global multiplier
radius: parseFloat(radiusInput.value), // pull radius in px
enabled: enabledInput.checked
};
// Live pointer position (exact) and eased ring position.
var pointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
var ringPos = { x: pointer.x, y: pointer.y };
var pointerSeen = false;
// Build the list of magnetic targets with per-element eased offset state.
var targets = [];
var nodes = doc.querySelectorAll("[data-magnet]");
for (var i = 0; i < nodes.length; i++) {
var el = nodes[i];
targets.push({
el: el,
label: el.querySelector("[data-magnet-label]"),
strength: parseFloat(el.getAttribute("data-strength")) || 0.4,
cx: 0,
cy: 0,
w: 0,
h: 0,
// current eased translate
x: 0,
y: 0,
// goal translate
gx: 0,
gy: 0
});
}
var captured = null; // currently magnetised target
var hovering = false;
// ---- Geometry ------------------------------------------------------------
function measure() {
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
// Read layout from the *rest* position by subtracting current translate.
var r = t.el.getBoundingClientRect();
t.w = r.width;
t.h = r.height;
t.cx = r.left + r.width / 2 - t.x;
t.cy = r.top + r.height / 2 - t.y;
}
}
var measureQueued = false;
function queueMeasure() {
if (measureQueued) return;
measureQueued = true;
requestAnimationFrame(function () {
measureQueued = false;
measure();
});
}
// ---- Pointer tracking ----------------------------------------------------
function onMove(e) {
pointer.x = e.clientX;
pointer.y = e.clientY;
if (!pointerSeen) {
pointerSeen = true;
ringPos.x = pointer.x;
ringPos.y = pointer.y;
}
if (rPointer) {
rPointer.textContent =
Math.round(pointer.x) + ", " + Math.round(pointer.y);
}
}
function onDown() {
if (cursor) cursor.classList.add("is-down");
}
function onUp() {
if (cursor) cursor.classList.remove("is-down");
}
// ---- Main loop -----------------------------------------------------------
function tick() {
// Ring easing toward the exact pointer.
if (settings.enabled && !reduceMotion) {
ringPos.x += (pointer.x - ringPos.x) * settings.lag;
ringPos.y += (pointer.y - ringPos.y) * settings.lag;
} else {
ringPos.x = pointer.x;
ringPos.y = pointer.y;
}
if (!touchLike) {
if (dot) {
dot.style.transform =
"translate3d(" + pointer.x + "px," + pointer.y + "px,0) translate(-50%,-50%)";
}
if (ring) {
ring.style.transform =
"translate3d(" + ringPos.x + "px," + ringPos.y + "px,0) translate(-50%,-50%)";
}
}
// Magnetism: find the nearest target within radius.
var newCaptured = null;
var bestDist = Infinity;
if (settings.enabled) {
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
var dx = pointer.x - t.cx;
var dy = pointer.y - t.cy;
// Effective radius grows a little with the element half-size so big
// tiles feel grabbable from their edges, not just their center.
var reach = settings.radius + Math.max(t.w, t.h) * 0.35;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < reach) {
var pull = settings.strength * t.strength * (1 - dist / reach);
t.gx = dx * pull;
t.gy = dy * pull;
if (dist < bestDist) {
bestDist = dist;
newCaptured = t;
}
} else {
t.gx = 0;
t.gy = 0;
}
}
} else {
for (var j = 0; j < targets.length; j++) {
targets[j].gx = 0;
targets[j].gy = 0;
}
}
// Ease each target (and its label a touch stronger for parallax).
for (var k = 0; k < targets.length; k++) {
var tt = targets[k];
if (reduceMotion) {
tt.x = tt.gx;
tt.y = tt.gy;
} else {
tt.x += (tt.gx - tt.x) * 0.16;
tt.y += (tt.gy - tt.y) * 0.16;
}
// Snap tiny residuals to zero to avoid sub-pixel jitter forever.
if (Math.abs(tt.x) < 0.03 && Math.abs(tt.gx) === 0) tt.x = 0;
if (Math.abs(tt.y) < 0.03 && Math.abs(tt.gy) === 0) tt.y = 0;
tt.el.style.transform =
"translate3d(" + tt.x.toFixed(2) + "px," + tt.y.toFixed(2) + "px,0)";
if (tt.label) {
tt.label.style.transform =
"translate3d(" +
(tt.x * 0.35).toFixed(2) +
"px," +
(tt.y * 0.35).toFixed(2) +
"px,0)";
}
}
// Update hover state on the cursor ring.
var isHover = !!newCaptured;
if (isHover !== hovering) {
hovering = isHover;
if (cursor) cursor.classList.toggle("is-hovering", hovering);
}
if (newCaptured !== captured) {
captured = newCaptured;
if (rCaptured) {
rCaptured.textContent = captured
? describe(captured.el)
: "none";
}
}
requestAnimationFrame(tick);
}
function describe(el) {
var label = el.querySelector("[data-magnet-label]");
var txt = label ? label.textContent.trim() : el.textContent.trim();
if (!txt) txt = el.getAttribute("aria-label") || el.tagName.toLowerCase();
return txt.length > 16 ? txt.slice(0, 15) + "…" : txt;
}
// ---- Controls ------------------------------------------------------------
function fmtLag() {
if (lagOut) lagOut.textContent = settings.lag.toFixed(2);
}
function fmtStrength() {
if (strengthOut)
strengthOut.innerHTML = settings.strength.toFixed(2) + "×";
}
function fmtRadius() {
if (radiusOut) radiusOut.textContent = Math.round(settings.radius) + "px";
}
lagInput.addEventListener("input", function () {
settings.lag = parseFloat(lagInput.value);
fmtLag();
});
strengthInput.addEventListener("input", function () {
settings.strength = parseFloat(strengthInput.value);
fmtStrength();
});
radiusInput.addEventListener("input", function () {
settings.radius = parseFloat(radiusInput.value);
fmtRadius();
});
enabledInput.addEventListener("change", function () {
settings.enabled = enabledInput.checked;
applyEnabled();
});
function applyEnabled() {
var on = settings.enabled && !touchLike;
body.classList.toggle("cursor-active", on);
if (rStatus) {
rStatus.textContent = settings.enabled
? touchLike
? "touch (native)"
: reduceMotion
? "reduced motion"
: "active"
: "disabled";
}
}
// ---- Reduced-motion live updates ----------------------------------------
function onReduceChange(e) {
reduceMotion = e.matches;
applyEnabled();
}
if (reduceMotionMQ) {
if (reduceMotionMQ.addEventListener) {
reduceMotionMQ.addEventListener("change", onReduceChange);
} else if (reduceMotionMQ.addListener) {
reduceMotionMQ.addListener(onReduceChange);
}
}
// ---- Wire up -------------------------------------------------------------
fmtLag();
fmtStrength();
fmtRadius();
if (touchLike) {
// Native pointer only: hide controls that don't apply, note the mode.
if (modeNote) {
modeNote.innerHTML =
"<strong>Touch device detected.</strong> The custom cursor is hidden; " +
"targets keep their normal focus and hover states.";
}
applyEnabled();
} else {
if (modeNote) {
modeNote.innerHTML = reduceMotion
? "<strong>Reduced motion.</strong> Lag and magnetic drift are disabled; " +
"hover and focus styles remain."
: "Tip: raise <strong>Magnet strength</strong> and <strong>Pull radius</strong>, " +
"then glide slowly past a target to feel it catch.";
}
window.addEventListener("mousemove", onMove, { passive: true });
window.addEventListener("mousedown", onDown, { passive: true });
window.addEventListener("mouseup", onUp, { passive: true });
window.addEventListener("mouseleave", function () {
if (cursor) cursor.classList.remove("is-hovering", "is-down");
});
applyEnabled();
}
window.addEventListener("resize", queueMeasure, { passive: true });
window.addEventListener("scroll", queueMeasure, { passive: true });
measure();
requestAnimationFrame(tick);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Magnetic Cursor</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Custom cursor layer (hidden on touch / reduced-motion via JS + CSS) -->
<div class="cursor" id="cursor" aria-hidden="true">
<div class="cursor__ring" id="cursorRing"></div>
<div class="cursor__dot" id="cursorDot"></div>
</div>
<main class="page" id="page">
<header class="head">
<p class="eyebrow">UI Components</p>
<h1>Magnetic Cursor</h1>
<p class="lede">
A custom dot-and-ring cursor with easing lag, plus targets that pull toward
the pointer inside a radius and snap back on leave. Move your mouse near the
elements below.
</p>
</header>
<section class="stage" aria-label="Magnetic targets">
<div class="stage__grid">
<button class="magnet btn-primary" data-magnet data-strength="0.4">
<span class="magnet__label" data-magnet-label>Get started</span>
</button>
<a class="magnet tile" href="#" data-magnet data-strength="0.55" aria-label="Explore docs">
<span class="magnet__label tile__glyph" data-magnet-label>↗</span>
<span class="tile__cap">Docs</span>
</a>
<button class="magnet btn-ghost" data-magnet data-strength="0.35">
<span class="magnet__label" data-magnet-label>Preview</span>
</button>
<a class="magnet chip" href="#" data-magnet data-strength="0.5">
<span class="magnet__label" data-magnet-label>★ Star</span>
</a>
<button class="magnet icon-round" data-magnet data-strength="0.6" aria-label="Play">
<span class="magnet__label" data-magnet-label>▶</span>
</button>
<a class="magnet link-inline" href="#" data-magnet data-strength="0.3">
<span class="magnet__label" data-magnet-label>Read the guide</span>
</a>
</div>
<p class="stage__hint">Every target is a real, keyboard-focusable button or link.</p>
</section>
<aside class="panel" aria-label="Controls">
<div class="panel__row panel__row--toggle">
<label class="switch">
<input type="checkbox" id="enabled" checked />
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
<span class="switch__text">Magnetic effect</span>
</label>
</div>
<div class="control">
<div class="control__head">
<label for="lag">Ring lag</label>
<output id="lagOut" for="lag">0.18</output>
</div>
<input type="range" id="lag" min="0.04" max="0.5" step="0.01" value="0.18" />
</div>
<div class="control">
<div class="control__head">
<label for="strength">Magnet strength</label>
<output id="strengthOut" for="strength">1.0×</output>
</div>
<input type="range" id="strength" min="0" max="2" step="0.05" value="1" />
</div>
<div class="control">
<div class="control__head">
<label for="radius">Pull radius</label>
<output id="radiusOut" for="radius">120px</output>
</div>
<input type="range" id="radius" min="40" max="240" step="5" value="120" />
</div>
<dl class="readout" aria-live="polite">
<div><dt>Pointer</dt><dd id="rPointer">—</dd></div>
<div><dt>Captured</dt><dd id="rCaptured">none</dd></div>
<div><dt>Status</dt><dd id="rStatus">active</dd></div>
</dl>
</aside>
<footer class="foot">
<p id="modeNote" class="note"></p>
</footer>
</main>
<script src="script.js"></script>
</body>
</html>Magnetic Cursor replaces the native pointer with a two-part cursor: a crisp dot that tracks the mouse exactly and a translucent ring that trails behind with spring-like easing. Around the page sit several magnetic targets — buttons, icon tiles, and links — that sense the pointer within a configurable radius and drift toward it, pulling their inner label along at a slightly stronger factor for a parallax feel. When the pointer leaves, every element eases back to rest, and hovered targets swell the ring so the cursor reads as “locked on.”
The interaction is driven by a single requestAnimationFrame loop that linearly interpolates the ring and each active target toward its goal, so motion stays smooth without piling up timers. Pointer offset is computed against each element’s center, clamped to the radius, and multiplied by a strength factor you can adjust live. Controls let you tune ring lag, magnet strength, and pull radius, and toggle the whole effect, while a readout reports the pointer coordinates and the currently captured target.
The component feature-detects coarse pointers and hides the custom cursor on touch devices, restoring the system pointer. It honors prefers-reduced-motion by disabling lag and magnetic drift while keeping focus outlines and hover states intact, and every target remains a real, keyboard-focusable element so the page works with or without the effect.