← All particles

Cursor Attract

A lattice of points that leans toward the pointer and relaxes when it leaves.

interactiveresponsivecalmfuturistic150 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.

302 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Cursor Attract
 *
 * A field of points that leans toward the pointer and relaxes when it
 * leaves. The technique is that nothing ever leaves home: each point
 * owns a fixed anchor on a jittered lattice, and the pointer only sets
 * a *target offset* from that anchor — a lean, capped in length, that
 * falls off with distance. A critically damped spring carries the point
 * to the target and back.
 *
 * Two problems disappear because of it. Nothing accumulates at the
 * cursor, so the field cannot be emptied by waving at it — which is
 * what happens when particles accelerate toward the pointer. And the
 * relaxation has no wobble to it: at a damping ratio of 1 the lattice
 * settles once and stays settled, where an underdamped return leaves
 * the whole field jiggling after the pointer has gone.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `spacing`, `radius`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CursorAttractProps = {
  /** Visual character of the response. */
  variant?: "subtle" | "default" | "playful";
  /** Px between lattice anchors. Overrides the variant's density. */
  spacing?: number;
  /** How far the pointer's influence reaches, in px. */
  radius?: number;
  /** Point color. Defaults to the inherited text color. */
  color?: string;
};

type VariantConfig = {
  /** Px between lattice anchors at this setting. */
  spacing: number;
  /** Furthest a point will lean from its anchor, in px. */
  lean: number;
  /** Spring stiffness. Damping is derived from it, at a ratio of 1. */
  stiffness: number;
  /** Point radius in px at rest. */
  dot: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A sparse lattice that barely acknowledges the pointer.
  subtle: { spacing: 30, lean: 9, stiffness: 90, dot: 1.2 },
  // Clearly responsive, still calm. All-purpose.
  default: { spacing: 24, lean: 16, stiffness: 110, dot: 1.5 },
  // Denser, leaning further, arriving quicker.
  playful: { spacing: 19, lean: 26, stiffness: 150, dot: 1.8 },
};

type Point = {
  /** Home. Never changes, which is the whole idea. */
  anchorX: number;
  anchorY: number;
  x: number;
  y: number;
  vx: number;
  vy: number;
  /** Per-point scale on the lean, so the field is not a rubber sheet. */
  give: number;
};

/** Below this movement, the field is at rest and the loop can stop. */
const REST = 0.05;

export default function CursorAttract({
  variant = "default",
  spacing,
  radius = 110,
  color,
}: CursorAttractProps) {
  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 gap = spacing ?? config.spacing;
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const ink = color ?? getComputedStyle(canvas).color ?? "#888888";
    // Critical damping: c = 2·sqrt(k·m) with m = 1. Anything softer and
    // the lattice keeps twitching after the pointer has left.
    const damping = 2 * Math.sqrt(config.stiffness);

    let width = 0;
    let height = 0;
    let ratio = 1;
    let points: Point[] = [];

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    const build = () => {
      points = [];
      const columns = Math.max(2, Math.floor(width / gap));
      const rows = Math.max(2, Math.floor(height / gap));
      const stepX = width / columns;
      const stepY = height / rows;
      for (let column = 0; column <= columns; column++) {
        for (let row = 0; row <= rows; row++) {
          // A little jitter on the anchors: a perfect grid reads as a
          // texture swatch, and the eye stops seeing the points at all.
          const anchorX = column * stepX + random(-stepX * 0.18, stepX * 0.18);
          const anchorY = row * stepY + random(-stepY * 0.18, stepY * 0.18);
          points.push({
            anchorX,
            anchorY,
            x: anchorX,
            y: anchorY,
            vx: 0,
            vy: 0,
            give: random(0.75, 1.25),
          });
        }
      }
    };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      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;

    const render = () => {
      context.clearRect(0, 0, width, height);
      context.fillStyle = ink;
      for (const point of points) {
        // Displacement doubles as the highlight: a point that has moved
        // is a point near the pointer, so no second distance test.
        const shift = Math.hypot(point.x - point.anchorX, point.y - point.anchorY);
        const lit = Math.min(1, shift / (config.lean * 0.9));
        context.globalAlpha = 0.3 + lit * 0.6;
        context.beginPath();
        context.arc(point.x, point.y, config.dot * (1 + lit * 0.5), 0, Math.PI * 2);
        context.fill();
      }
      context.globalAlpha = 1;
    };

    // Reduced motion: the lattice at rest, and no listeners at all.
    // Following a pointer is the effect; there is no reduced version of
    // it, 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();

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;

      // 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 = radius * 0.5;
        if (x < -margin || y < -margin || x > width + margin || y > height + margin) {
          pointerX = null;
          pointerY = null;
        } else {
          pointerX = x;
          pointerY = y;
        }
      }

      let moving = false;
      for (const point of points) {
        let targetX = point.anchorX;
        let targetY = point.anchorY;

        if (pointerX !== null && pointerY !== null) {
          const dx = pointerX - point.anchorX;
          const dy = pointerY - point.anchorY;
          const distance = Math.hypot(dx, dy);
          if (distance < radius && distance > 0.001) {
            // Squared falloff: linear leaves a visible ring at the edge
            // of the influence, where every point moves the same little.
            const pull = (1 - distance / radius) ** 2;
            const reach = Math.min(config.lean * point.give * pull, distance * 0.8);
            targetX += (dx / distance) * reach;
            targetY += (dy / distance) * reach;
          }
        }

        const hold = Math.exp(-damping * delta);
        point.vx = (point.vx + (targetX - point.x) * config.stiffness * delta) * hold;
        point.vy = (point.vy + (targetY - point.y) * config.stiffness * delta) * hold;
        point.x += point.vx * delta;
        point.y += point.vy * delta;

        if (
          Math.abs(point.vx) > REST ||
          Math.abs(point.vy) > REST ||
          Math.abs(point.x - targetX) > REST ||
          Math.abs(point.y - targetY) > REST
        ) {
          moving = true;
        }
      }

      render();

      if (!moving) {
        // Settled — whether relaxed or held in a lean by a pointer that
        // has stopped. Park the loop rather than redraw an unchanging
        // lattice; the next pointer event wakes it.
        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 on the canvas keeps the canvas
    // fully transparent to the pointer: content can sit on top of the
    // field and stay clickable. 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;
      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, spacing, radius, color]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{ width: "100%", height: "100%", display: "block" }}
    />
  );
}

About this effect

An interactive field for a panel that should feel touchable — a hero, an empty canvas, a chooser. Nothing ever leaves home: each point owns a fixed anchor on a jittered lattice, and the pointer only sets a target offset from it, a lean capped in length that falls off with the square of distance. A critically damped spring carries the point out and back. That single decision solves both failure modes of the usual version — nothing accumulates at the cursor, so the field cannot be swept empty, and the return has no wobble, where an underdamped spring leaves the whole lattice jiggling after the pointer has gone. It listens on the window rather than on the canvas, so content on top stays clickable, and the loop parks itself once the field has settled.

Hero panelEmpty canvasOption chooserLanding background

Related effects