← All particles

Scatter On Press

A tile of points that opens away from a press and closes back into itself on release.

interactiveplayfulbold150 particles · light · canvas-2d · interaction · finite
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.

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

/**
 * Vibary · Scatter On Press
 *
 * A tile made of points. Press it and it opens away from your finger;
 * let go and it closes back into exactly the tile it was.
 *
 * The technique: the press is a force applied for as long as it is held,
 * not a burst fired once. Every point is on a spring to its own home,
 * and the press adds an outward push that falls off with distance, so
 * the scatter is the *balance* of the two — which makes the gesture
 * legible in two ways at no cost. Hold time matters, because reaching
 * the balance takes about a quarter of a second: a tap opens the tile a
 * tenth of the way and a held press opens it fully. And nothing can ever
 * be blown off the surface, because the balance is a fixed distance —
 * simulated, a point 14px from the press settles 61px out whether it is
 * held for a second or a minute. A canned burst has neither property:
 * it plays the same length whatever you do, and its travel is whatever
 * the random velocities happened to be.
 *
 * The return is exactly critically damped, damping = 2·sqrt(stiffness),
 * so the tile closes once and stays closed. Anything softer leaves a
 * hundred and fifty points jittering after the gesture is over, which is
 * the difference between a solid object and a bag of sand.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `count`, `color`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ScatterOnPressProps = {
  /** Visual character of the response. */
  variant?: "subtle" | "default" | "playful";
  /** Points in the tile. */
  count?: number;
  /** Point colour. Defaults to the inherited text colour. */
  color?: string;
  /** Fires when a press begins. */
  onScatter?: () => void;
  /** After this long without input, press on its own. 0 disables. */
  idleDemoSeconds?: number;
};

type VariantConfig = {
  /** Spring stiffness. Damping is derived from it, at a ratio of 1. */
  stiffness: number;
  /** Push at the press point, before the distance falloff. */
  strength: number;
  /** Distance at which the push has halved, in px. */
  falloff: number;
  /** Point radius in px. */
  dot: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Gives a little and closes quickly: for a control, not a toy.
  subtle: { stiffness: 78, strength: 8800, falloff: 44, dot: 1.7 },
  // Opens clearly, closes in one move. All-purpose.
  default: { stiffness: 62, strength: 10640, falloff: 55, dot: 2 },
  // A wider reach and further travel — the tile really comes apart.
  playful: { stiffness: 50, strength: 12500, falloff: 68, dot: 2.3 },
};

/** Inside this, and slow, a point is placed home and leaves the loop. */
const REST = 0.4;
const REST_SPEED = 8;

type Point = {
  homeX: number;
  homeY: number;
  x: number;
  y: number;
  vx: number;
  vy: number;
  /** Per-point scale on the push, so the tile is not a rubber balloon. */
  give: number;
};

export default function ScatterOnPress({
  variant = "default",
  count = 150,
  color,
  onScatter,
  idleDemoSeconds = 0,
}: ScatterOnPressProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The parent's callback is read through a ref so an inline arrow can't
  // rebuild the tile on every render — assigned in an effect, because a
  // ref write during render is a side effect mid-render.
  const scatterRef = useRef(onScatter);
  useEffect(() => {
    scatterRef.current = onScatter;
  }, [onScatter]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const wanted = Math.max(12, Math.round(count));
    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 points: Point[] = [];
    let inset = 14;
    let corner = 18;

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

    const build = () => {
      inset = Math.min(16, Math.min(width, height) * 0.08);
      const boxWidth = Math.max(1, width - inset * 2);
      const boxHeight = Math.max(1, height - inset * 2);
      corner = Math.min(22, Math.min(boxWidth, boxHeight) * 0.22);

      // A hex lattice, so the tile reads as one material rather than as
      // a grid — and the pitch is solved from the requested count, so
      // the number is the same whatever size the box turns out to be.
      const pitch = Math.sqrt((boxWidth * boxHeight) / (wanted * 0.866));
      const rowPitch = pitch * 0.866;
      points = [];
      for (let row = 0; row * rowPitch <= boxHeight; row++) {
        const y = inset + row * rowPitch + rowPitch * 0.5;
        if (y > height - inset) break;
        const stagger = row % 2 === 0 ? 0 : pitch * 0.5;
        for (let column = 0; ; column++) {
          const x = inset + stagger + column * pitch + pitch * 0.5;
          if (x > width - inset) break;
          // Round the tile's corners by rejecting points outside them.
          const cx = Math.max(inset + corner, Math.min(width - inset - corner, x));
          const cy = Math.max(inset + corner, Math.min(height - inset - corner, y));
          if (Math.hypot(x - cx, y - cy) > corner) continue;
          points.push({
            homeX: x,
            homeY: y,
            x,
            y,
            vx: 0,
            vy: 0,
            give: random(0.78, 1.28),
          });
        }
      }
    };

    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();

    const render = () => {
      context.clearRect(0, 0, width, height);
      context.fillStyle = ink;
      for (const point of points) {
        // Displacement thins the point: an open tile is the same
        // material spread over more surface, so it has to read lighter.
        const shift = Math.hypot(point.x - point.homeX, point.y - point.homeY);
        const spread = Math.min(1, shift / 70);
        context.globalAlpha = 0.72 - spread * 0.34;
        context.beginPath();
        context.arc(point.x, point.y, config.dot * (1 - spread * 0.22), 0, Math.PI * 2);
        context.fill();
      }
      context.globalAlpha = 1;
    };

    /** Where a point ends up if the press is held: the force balance. */
    const settleAt = (point: Point, pressX: number, pressY: number) => {
      const dx = point.homeX - pressX;
      const dy = point.homeY - pressY;
      const start = Math.hypot(dx, dy) || 0.001;
      let distance = 0;
      for (let step = 0; step < 24; step++) {
        const r = start + distance;
        const push = (config.strength * point.give) / (1 + (r / config.falloff) ** 2);
        distance = distance * 0.6 + 0.4 * (push / config.stiffness);
      }
      return {
        x: point.homeX + (dx / start) * distance,
        y: point.homeY + (dy / start) * distance,
      };
    };

    let pressX = 0;
    let pressY = 0;
    let pressed = false;

    const inside = (x: number, y: number) =>
      x >= 0 && y >= 0 && x <= width && y <= height;

    // Reduced motion: the tile answers the press, it just does not
    // travel there. Down puts every point at the balance it would have
    // reached; up puts it home. The information — this thing gives way
    // under your finger, and where you press decides how — survives
    // intact, and no frame is animated.
    if (reduced) {
      render();
      const draw = () => {
        if (pressed) {
          for (const point of points) {
            const rest = settleAt(point, pressX, pressY);
            point.x = rest.x;
            point.y = rest.y;
          }
        } else {
          for (const point of points) {
            point.x = point.homeX;
            point.y = point.homeY;
          }
        }
        render();
      };
      const onDownStill = (event: PointerEvent) => {
        const rect = canvas.getBoundingClientRect();
        const x = event.clientX - rect.left;
        const y = event.clientY - rect.top;
        if (!inside(x, y)) return;
        pressX = x;
        pressY = y;
        pressed = true;
        scatterRef.current?.();
        draw();
      };
      const onUpStill = () => {
        if (!pressed) return;
        pressed = false;
        draw();
      };
      const onResizeStill = () => {
        resize();
        draw();
      };
      window.addEventListener("pointerdown", onDownStill);
      window.addEventListener("pointerup", onUpStill);
      window.addEventListener("pointercancel", onUpStill);
      window.addEventListener("resize", onResizeStill);
      return () => {
        window.removeEventListener("pointerdown", onDownStill);
        window.removeEventListener("pointerup", onUpStill);
        window.removeEventListener("pointercancel", onUpStill);
        window.removeEventListener("resize", onResizeStill);
      };
    }

    let frame = 0;
    let sleeping = false;
    let last = performance.now();
    let lastInput = performance.now();
    let demoUntil = 0;
    /** Raw client coords, converted to canvas space once per frame. */
    let downClientX = 0;
    let downClientY = 0;
    let downSeen = false;
    let moveClientX = 0;
    let moveClientY = 0;
    let moveSeen = false;
    let upSeen = false;
    let pointerEventAt = 0;

    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 handlers
      // only record client coordinates, so dragging a held press at
      // 120Hz never forces synchronous layout per event. Down is applied
      // before move and move before up, so a press and its release both
      // land even when they arrive between two frames.
      if (downSeen || moveSeen) {
        const rect = canvas.getBoundingClientRect();
        if (downSeen) {
          downSeen = false;
          const x = downClientX - rect.left;
          const y = downClientY - rect.top;
          if (inside(x, y)) {
            pressX = x;
            pressY = y;
            pressed = true;
            demoUntil = 0;
            lastInput = pointerEventAt;
            scatterRef.current?.();
          }
        }
        if (moveSeen) {
          moveSeen = false;
          if (pressed && demoUntil === 0) {
            pressX = moveClientX - rect.left;
            pressY = moveClientY - rect.top;
            lastInput = pointerEventAt;
          }
        }
      }
      if (upSeen) {
        upSeen = false;
        if (pressed && demoUntil === 0) {
          pressed = false;
          lastInput = pointerEventAt;
        }
      }

      if (idleDemoSeconds > 0 && !pressed && now - lastInput > idleDemoSeconds * 1000) {
        if (now > demoUntil) {
          // A press somewhere in the tile, held long enough to open it.
          pressX = random(width * 0.24, width * 0.76);
          pressY = random(height * 0.26, height * 0.74);
          pressed = true;
          demoUntil = now + 620;
          scatterRef.current?.();
        }
      }
      if (pressed && demoUntil > 0 && now > demoUntil) {
        pressed = false;
        demoUntil = 0;
        lastInput = now - idleDemoSeconds * 1000 + 900;
      }

      let moving = false;
      for (const point of points) {
        let ax = (point.homeX - point.x) * config.stiffness;
        let ay = (point.homeY - point.y) * config.stiffness;

        if (pressed) {
          const dx = point.x - pressX;
          const dy = point.y - pressY;
          const r = Math.hypot(dx, dy) || 0.001;
          const push = (config.strength * point.give) / (1 + (r / config.falloff) ** 2);
          ax += (dx / r) * push;
          ay += (dy / r) * push;
        }

        point.vx = (point.vx + ax * delta) * Math.exp(-damping * delta);
        point.vy = (point.vy + ay * delta) * Math.exp(-damping * delta);
        point.x += point.vx * delta;
        point.y += point.vy * delta;

        const shift = Math.hypot(point.x - point.homeX, point.y - point.homeY);
        const speed = Math.hypot(point.vx, point.vy);
        if (!pressed && shift < REST && speed < REST_SPEED) {
          point.x = point.homeX;
          point.y = point.homeY;
          point.vx = 0;
          point.vy = 0;
        } else {
          moving = true;
        }
      }

      render();

      if (!moving && !pressed && idleDemoSeconds <= 0) {
        // Closed and still. Park the loop; the next press wakes it.
        sleeping = true;
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    const wake = () => {
      if (!sleeping) return;
      sleeping = false;
      last = performance.now();
      frame = requestAnimationFrame(tick);
    };

    // The handlers deliberately do no layout work and no canvas-space
    // math: they store the event and let the frame loop convert it —
    // which is also where the inside test now lives.
    const onPointerDown = (event: PointerEvent) => {
      downClientX = event.clientX;
      downClientY = event.clientY;
      downSeen = true;
      // A move or release still waiting belongs to the gesture before
      // this press, and must not act after it.
      moveSeen = false;
      upSeen = false;
      pointerEventAt = performance.now();
      wake();
    };

    const onPointerMove = (event: PointerEvent) => {
      // The gate reads last frame's state, at most one event stale — a
      // pending press counts, so a drag's first move is not dropped, and
      // hover moves stay inert so a parked loop stays parked.
      if (!downSeen && (!pressed || demoUntil > 0)) return;
      moveClientX = event.clientX;
      moveClientY = event.clientY;
      moveSeen = true;
      pointerEventAt = performance.now();
    };

    const onPointerUp = () => {
      if (!downSeen && (!pressed || demoUntil > 0)) return;
      upSeen = true;
      pointerEventAt = performance.now();
      wake();
    };

    window.addEventListener("pointerdown", onPointerDown);
    window.addEventListener("pointermove", onPointerMove, { passive: true });
    window.addEventListener("pointerup", onPointerUp);
    window.addEventListener("pointercancel", onPointerUp);
    frame = requestAnimationFrame(tick);

    const onResize = () => {
      resize();
      wake();
    };
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("pointerdown", onPointerDown);
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerup", onPointerUp);
      window.removeEventListener("pointercancel", onPointerUp);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, count, color, idleDemoSeconds]);

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

About this effect

Physical feedback for a press that should feel like touching something rather than clicking it — a long-press affordance, a card being picked up, a control that admits it has substance. The press is a force applied for as long as it is held rather than a burst fired once, and that single choice makes the gesture legible twice over. Hold time matters, because reaching the balance between the outward push and each point's spring takes about a quarter of a second, so a tap opens the tile a tenth of the way and a held press opens it fully. And nothing can be blown off the surface, because the balance is a fixed distance: a point 14px from the press settles 61px out whether it is held for a second or a minute. Where you press matters too — the push falls off with distance, so the near points travel about three times as far as the far ones. The return is exactly critically damped, so a hundred and fifty points close once and stay closed instead of jittering after the gesture is over.

Long-press affordanceCard pick-up feedbackTactile button or tilePress-to-reveal control

Related effects