← All particles

Magnetic Snap

Filings that belong to the nearest socket, let go for a passing pointer, and snap back.

interactiveplayfultechnical90 particles · light · canvas-2d · interaction · looping
Interactive · try it
Variant

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.

420 lines · react only
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.

Drop targets on a boardPreset or workspace pickerOnboarding surface that invites a swipeEmpty state that responds to the cursor

Related effects