Magnetic Snap
Filings that belong to the nearest socket, let go for a passing pointer, and snap back.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Magnetic Snap
*
* Filings that belong to the nearest of several sockets. Sweep a pointer
* through them and they let go, follow it, and snap back into whichever
* socket they end up closest to.
*
* The technique: both of this effect's edges are bands rather than
* lines. Allegiance is sticky — a filing only changes socket when
* another is closer by a clear margin, not merely closer. Without that
* band, a filing resting on the midline between two sockets flips target
* on the noise of its own spring and buzzes there forever: simulated,
* one filing drifting across a boundary switches 49 times bare and once
* with a 14px band. And arrival is an event — inside a small capture
* radius the filing is placed exactly on its slot, its velocity is
* cleared, and it stops being integrated at all. A spring alone only
* ever approaches, so the field never quite stops shimmering and never
* actually snaps.
*
* Two consequences fall out for free. A settled field costs nothing:
* every filing is locked, so the loop parks itself and the next pointer
* event wakes it. And each filing is drawn as a dash pointing at its own
* socket, which is the same vector the spring is already using — so the
* field lines are visible without computing anything twice.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `sockets`, `count`, `reach`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type MagneticSnapProps = {
/** Visual character of the response. */
variant?: "subtle" | "default" | "playful";
/** How many sockets the filings can belong to. */
sockets?: number;
/** Filings per socket. The total is this times `sockets`. */
perSocket?: number;
/** How far the pointer's pull reaches, in px. */
reach?: number;
/** Filing colour. Defaults to the inherited text colour. */
color?: string;
/** After this long without input, trace a pointer path. 0 disables. */
idleDemoSeconds?: number;
};
type VariantConfig = {
/** Spring stiffness. Damping is derived from it, at a ratio of 1. */
stiffness: number;
/** Radius of a socket's rosette, in px. */
socketRadius: number;
/** Dash length in px. */
dash: number;
/** How far another socket must beat the current one to steal a filing. */
margin: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Tight clusters that give way slowly and re-form without fuss.
subtle: { stiffness: 110, socketRadius: 12, dash: 3.2, margin: 20 },
// Clearly magnetic, snaps audibly. All-purpose.
default: { stiffness: 175, socketRadius: 16, dash: 4.4, margin: 14 },
// Looser rosettes, a quicker snap, filings that travel further.
playful: { stiffness: 255, socketRadius: 22, dash: 5.6, margin: 10 },
};
/** Inside this distance of its slot, a filing is placed and stops. */
const CAPTURE = 1.3;
/** And below this speed, so it captures on arrival rather than in flight. */
const CAPTURE_SPEED = 26;
/** Seconds of highlight when a filing takes its slot. */
const FLASH = 0.24;
/** Golden angle — even rosettes with no clumping. */
const GOLDEN = 2.399963;
type Filing = {
x: number;
y: number;
vx: number;
vy: number;
socket: number;
/** Fixed offset within whatever socket it belongs to. */
slotX: number;
slotY: number;
locked: boolean;
flash: number;
};
export default function MagneticSnap({
variant = "default",
sockets = 5,
perSocket = 18,
reach = 78,
color,
idleDemoSeconds = 0,
}: MagneticSnapProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const socketCount = Math.max(2, Math.round(sockets));
const perCount = Math.max(3, Math.round(perSocket));
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ink = color ?? getComputedStyle(canvas).color ?? "#888888";
const damping = 2 * Math.sqrt(config.stiffness);
let width = 0;
let height = 0;
let anchors: { x: number; y: number }[] = [];
let filings: Filing[] = [];
const build = () => {
// Sockets on a staggered grid, inset from the edges so a filing
// never has to settle half off the surface.
const columns = Math.ceil(Math.sqrt(socketCount));
const rows = Math.ceil(socketCount / columns);
anchors = [];
for (let index = 0; index < socketCount; index++) {
const column = index % columns;
const row = Math.floor(index / columns);
const stagger = row % 2 === 0 ? 0 : 0.5;
anchors.push({
x: width * (0.16 + ((column + stagger + 0.5) / (columns + 0.5)) * 0.68),
y: height * (0.2 + ((row + 0.5) / rows) * 0.6),
});
}
filings = [];
for (let socket = 0; socket < socketCount; socket++) {
for (let slot = 0; slot < perCount; slot++) {
// Sunflower packing: even coverage of the rosette, and the
// same layout whatever the population. The angle runs off the
// filing's global index rather than its slot, so two filings
// that end up sharing a socket cannot land on the same point
// — an exact overlap reads as material having gone missing.
const angle = (socket * perCount + slot) * GOLDEN;
const radius = config.socketRadius * Math.sqrt((slot + 0.6) / perCount);
const slotX = Math.cos(angle) * radius;
const slotY = Math.sin(angle) * radius;
filings.push({
x: anchors[socket].x + slotX,
y: anchors[socket].y + slotY,
vx: 0,
vy: 0,
socket,
slotX,
slotY,
locked: true,
flash: 0,
});
}
}
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
build();
};
resize();
let pointerX: number | null = null;
let pointerY: number | null = null;
/** Raw client coords, converted to canvas space once per frame. */
let pointerClientX = 0;
let pointerClientY = 0;
let pointerSeen = false;
let pointerEventAt = 0;
const render = () => {
context.clearRect(0, 0, width, height);
context.strokeStyle = ink;
context.lineWidth = 1;
context.globalAlpha = 0.16;
for (const anchor of anchors) {
context.beginPath();
context.arc(anchor.x, anchor.y, config.socketRadius + 5, 0, Math.PI * 2);
context.stroke();
}
context.lineCap = "round";
for (const filing of filings) {
const anchor = anchors[filing.socket];
// The dash points along the same vector the spring is pulling
// on, so the field lines cost nothing extra.
const toX = (pointerX !== null && !filing.locked ? pointerX : anchor.x) - filing.x;
const toY = (pointerY !== null && !filing.locked ? pointerY : anchor.y) - filing.y;
const span = Math.hypot(toX, toY) || 1;
const half = config.dash * 0.5;
const ux = (toX / span) * half;
const uy = (toY / span) * half;
context.globalAlpha =
0.32 + (filing.locked ? 0.28 : 0.44) + filing.flash * 1.4;
context.lineWidth = 1.5 + filing.flash * 2.2;
context.beginPath();
context.moveTo(filing.x - ux, filing.y - uy);
context.lineTo(filing.x + ux, filing.y + uy);
context.stroke();
}
context.globalAlpha = 1;
context.lineWidth = 1;
};
// Reduced motion: every filing in its socket. Sockets, rosettes and
// the field lines pointing into them are the whole idea, and none of
// them needs movement to be legible. No pointer listeners at all —
// following a pointer has no reduced version, only the field it
// happens to.
if (reduced) {
render();
const onResizeStill = () => {
resize();
render();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let sleeping = false;
let last = performance.now();
let lastInput = performance.now();
let elapsed = 0;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
// One rect read per frame, not one per pointer event: the handler
// only records client coordinates, so a 120Hz pointer on a page
// with live layout never forces synchronous layout per event.
if (pointerSeen) {
pointerSeen = false;
const rect = canvas.getBoundingClientRect();
const x = pointerClientX - rect.left;
const y = pointerClientY - rect.top;
const margin = reach * 0.6;
if (x < -margin || y < -margin || x > width + margin || y > height + margin) {
pointerX = null;
pointerY = null;
} else {
pointerX = x;
pointerY = y;
lastInput = pointerEventAt;
}
}
let magnetX = pointerX;
let magnetY = pointerY;
if (
idleDemoSeconds > 0 &&
now - lastInput > idleDemoSeconds * 1000 &&
width > 0
) {
// A slow tour, for a preview nobody can reach.
magnetX = width * (0.5 + 0.34 * Math.sin(elapsed * 0.55));
magnetY = height * (0.5 + 0.28 * Math.sin(elapsed * 0.87 + 1.1));
}
let awake = false;
for (const filing of filings) {
if (filing.flash > 0) {
filing.flash = Math.max(0, filing.flash - delta / FLASH);
awake = true;
}
let targetX: number;
let targetY: number;
let pulled = false;
if (magnetX !== null && magnetY !== null) {
const dx = magnetX - filing.x;
const dy = magnetY - filing.y;
if (Math.hypot(dx, dy) < reach) {
// Held by the pointer, in the same rosette it would take in a
// socket — so the cluster keeps its shape while it travels.
targetX = magnetX + filing.slotX * 0.85;
targetY = magnetY + filing.slotY * 0.85;
pulled = true;
} else {
targetX = 0;
targetY = 0;
}
} else {
targetX = 0;
targetY = 0;
}
if (pulled) {
filing.locked = false;
} else {
if (filing.locked) continue;
// Sticky allegiance. `margin` is the whole reason a filing on
// a boundary commits instead of buzzing between two sockets.
const current = anchors[filing.socket];
let bestDistance = Math.hypot(filing.x - current.x, filing.y - current.y);
for (let index = 0; index < anchors.length; index++) {
if (index === filing.socket) continue;
const distance = Math.hypot(
filing.x - anchors[index].x,
filing.y - anchors[index].y
);
if (distance < bestDistance - config.margin) {
bestDistance = distance;
filing.socket = index;
}
}
targetX = anchors[filing.socket].x + filing.slotX;
targetY = anchors[filing.socket].y + filing.slotY;
}
const hold = Math.exp(-damping * delta);
filing.vx =
(filing.vx + (targetX - filing.x) * config.stiffness * delta) * hold;
filing.vy =
(filing.vy + (targetY - filing.y) * config.stiffness * delta) * hold;
filing.x += filing.vx * delta;
filing.y += filing.vy * delta;
const gap = Math.hypot(targetX - filing.x, targetY - filing.y);
const speed = Math.hypot(filing.vx, filing.vy);
if (!pulled && gap < CAPTURE && speed < CAPTURE_SPEED) {
// Arrival, as an event: placed exactly, stopped, and out of the
// integrator until something reaches for it again.
filing.x = targetX;
filing.y = targetY;
filing.vx = 0;
filing.vy = 0;
filing.locked = true;
filing.flash = 1;
awake = true;
} else {
awake = true;
}
}
render();
// A settled field is a still image: park the loop rather than
// redraw it. The next pointer event wakes it. Only the optional
// self-tour has a reason to keep running.
if (!awake && magnetX === null && idleDemoSeconds <= 0) {
sleeping = true;
return;
}
frame = requestAnimationFrame(tick);
};
const wake = () => {
if (!sleeping) return;
sleeping = false;
last = performance.now();
frame = requestAnimationFrame(tick);
};
// Listening on the window rather than the canvas keeps the canvas
// transparent to the pointer, so content can sit on top of it.
// Deliberately does no layout work and no canvas-space math: it
// stores the event and lets the frame loop convert it.
const onPointerMove = (event: PointerEvent) => {
pointerClientX = event.clientX;
pointerClientY = event.clientY;
pointerEventAt = performance.now();
pointerSeen = true;
wake();
};
const onPointerLeave = () => {
pointerSeen = false;
pointerX = null;
pointerY = null;
wake();
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", onPointerMove, { passive: true });
document.addEventListener("pointerleave", onPointerLeave);
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
wake();
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerMove);
document.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("resize", onResize);
};
}, [variant, sockets, perSocket, reach, color, idleDemoSeconds]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
For a surface where things have places to be — a board with columns, a layout with drop targets, a set of presets. Both of its edges are bands rather than lines, and that is the whole effect. Allegiance is sticky: a filing changes socket only when another is closer by a clear margin, not merely closer, because a filing resting on a midline otherwise flips target on the noise of its own spring and buzzes there — simulated, one filing crossing a boundary switches 49 times bare and once with a 14px band. Arrival is an event: inside a small capture radius the filing is placed exactly on its slot, its velocity cleared, and it leaves the integrator, where a spring alone only ever approaches and so never actually snaps. Two things follow for free — a settled field parks the animation loop until the next pointer event, and each filing is drawn as a dash along the same vector the spring is already pulling on, so the field lines cost nothing extra.