Interaction
Magnet
Wrapper that pulls its child toward a nearby pointer, then springs back.
npx bezel-add add magnetAbout
A magnetic hover effect for React that pulls its child toward a nearby mouse pointer and springs it back when the pointer leaves the field. The spring is tuned to settle without wobble, the pull fades in at the edge of the field, and the loop stops when idle. It ignores touch input, stops in hidden tabs and turns off under prefers-reduced-motion.
Key props: padding (reach of the pull outside the element, default 140px), magnetStrength (lower pulls harder), maxOffset (furthest the child can move, default 44px), disabled (turns the effect off)
packages/ui/src/interaction/Magnet.tsx · 217 lines
"use client";
import {
useEffect,
useRef,
type HTMLAttributes,
type ReactNode,
} from "react";
type MagnetProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
/** Pull radius measured outward from the element box, in px. */
padding?: number;
disabled?: boolean;
/** Divisor on the pointer offset: lower numbers pull harder. */
magnetStrength?: number;
/** Ceiling on the offset in px, so the element stays near its layout box. */
maxOffset?: number;
wrapperClassName?: string;
innerClassName?: string;
};
// A spring just shy of critically damped: it catches the pointer quickly and
// settles without a visible wobble.
const STIFFNESS = 170;
const DAMPING = 26;
// Integration step, small enough that a long frame cannot make the spring blow up.
const MAX_STEP = 1 / 120;
export function Magnet({
children,
padding = 140,
disabled = false,
magnetStrength = 1.8,
maxOffset = 44,
wrapperClassName = "",
innerClassName = "",
...props
}: MagnetProps) {
const rootRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
const inner = innerRef.current;
if (!root || !inner || disabled) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const strength = Math.max(1, magnetStrength);
const cap = Math.max(0, maxOffset);
const reach = Math.max(0, padding);
let pointerX = 0;
let pointerY = 0;
let tracking = false;
let painted = false;
let targetX = 0;
let targetY = 0;
let x = 0;
let y = 0;
let vx = 0;
let vy = 0;
let frame = 0;
let last = 0;
const rest = () => {
if (frame) window.cancelAnimationFrame(frame);
frame = 0;
tracking = false;
targetX = 0;
targetY = 0;
x = 0;
y = 0;
vx = 0;
vy = 0;
if (painted) {
inner.style.transform = "";
inner.style.willChange = "";
painted = false;
}
};
const aim = () => {
// One layout read per frame, never one per event, and it happens before
// the write at the end of the tick.
const rect = root.getBoundingClientRect();
if (!rect.width || !rect.height) {
targetX = 0;
targetY = 0;
return;
}
const halfWidth = rect.width / 2;
const halfHeight = rect.height / 2;
// The wrapper is never transformed, so the centre it reports is the
// layout centre and the pull cannot chase its own output.
const dx = pointerX - (rect.left + halfWidth);
const dy = pointerY - (rect.top + halfHeight);
const reachX = halfWidth + reach;
const reachY = halfHeight + reach;
const spread =
(dx * dx) / (reachX * reachX) + (dy * dy) / (reachY * reachY);
if (spread >= 1) {
targetX = 0;
targetY = 0;
return;
}
// Fading the pull to nothing at the rim of the field means a cursor
// crossing the edge is eased in, not snapped.
const pull = (1 - spread) / strength;
let nextX = dx * pull;
let nextY = dy * pull;
const length = Math.hypot(nextX, nextY);
if (length > cap) {
nextX = (nextX / length) * cap;
nextY = (nextY / length) * cap;
}
targetX = nextX;
targetY = nextY;
};
const tick = (now: number) => {
frame = window.requestAnimationFrame(tick);
const elapsed = last ? Math.min((now - last) / 1000, 0.05) : MAX_STEP;
last = now;
if (tracking) aim();
let remaining = elapsed;
while (remaining > 0) {
const step = Math.min(remaining, MAX_STEP);
remaining -= step;
vx += (-STIFFNESS * (x - targetX) - DAMPING * vx) * step;
vy += (-STIFFNESS * (y - targetY) - DAMPING * vy) * step;
x += vx * step;
y += vy * step;
}
// Back home and still: drop the loop and the inline styles so an idle
// Magnet costs nothing.
if (
targetX === 0 &&
targetY === 0 &&
Math.hypot(x, y) < 0.05 &&
Math.hypot(vx, vy) < 2
) {
rest();
return;
}
if (!painted) {
inner.style.willChange = "transform";
painted = true;
}
inner.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0)`;
};
const start = () => {
if (frame) return;
last = 0;
frame = window.requestAnimationFrame(tick);
};
const move = (event: PointerEvent) => {
// Only a hovering mouse drives the pull. A finger has no hover to leave
// with, so it would park the element off centre with nothing to
// bring it back.
if (event.pointerType !== "mouse" || reduce.matches) return;
pointerX = event.clientX;
pointerY = event.clientY;
tracking = true;
start();
};
const release = () => {
tracking = false;
targetX = 0;
targetY = 0;
if (frame || x !== 0 || y !== 0) start();
};
const drop = (event: PointerEvent) => {
if (event.pointerType !== "mouse") rest();
};
const visibility = () => {
// A hidden tab throttles frames, so stop rather than resume mid flight.
if (document.hidden) rest();
};
const preference = () => {
if (reduce.matches) rest();
};
window.addEventListener("pointermove", move, { passive: true });
window.addEventListener("pointerdown", drop, { passive: true });
window.addEventListener("blur", release);
document.documentElement.addEventListener("pointerleave", release);
document.addEventListener("visibilitychange", visibility);
reduce.addEventListener("change", preference);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerdown", drop);
window.removeEventListener("blur", release);
document.documentElement.removeEventListener("pointerleave", release);
document.removeEventListener("visibilitychange", visibility);
reduce.removeEventListener("change", preference);
rest();
};
}, [disabled, magnetStrength, maxOffset, padding]);
return (
<div ref={rootRef} className={wrapperClassName} {...props}>
<div ref={innerRef} className={innerClassName}>
{children}
</div>
</div>
);
}