← All particles

Particle Assemble

A scattered cloud converging into a mark, drawn in stroke order.

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

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

/**
 * Vibary · Particle Assemble
 *
 * A scattered cloud converging into a glyph. The technique is where the
 * targets come from: the glyph is a stroked path, and the targets are
 * sampled along it by arc length — evenly spaced in distance travelled,
 * not per segment and not by throwing darts at a bitmap. Two things
 * fall out of that. The finished mark has constant density, so it reads
 * the instant it lands instead of clumping at the corners. And because
 * every particle knows how far along the stroke its target sits, that
 * same number becomes its delay: the mark draws itself in stroke order,
 * the way it would be written, rather than fading in everywhere at once.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; replay by incrementing `runKey`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ParticleAssembleProps = {
  /** Visual character of the arrival. */
  variant?: "subtle" | "default" | "playful";
  /** Which glyph the cloud lands on. */
  shape?: "check" | "ring";
  /** Increment to run it again. It also runs once on mount. */
  runKey?: number;
  /** Particles in the cloud. Overrides the variant's density. */
  count?: number;
  /** Particle color. Semantic: this is a confirmation. */
  color?: string;
  /** Drawing box in px. */
  size?: number;
  /** Fires once the last particle has arrived. */
  onAssembled?: () => void;
};

type VariantConfig = {
  /** Particles in the cloud at this setting. */
  count: number;
  /** Seconds one particle spends travelling. */
  travel: number;
  /** Seconds between the first departure and the last. */
  stagger: number;
  /** How far out the cloud starts, as a fraction of the box. */
  scatter: number;
  /** Particle radius in px once it has landed. */
  dot: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Close in, quick, barely a gesture.
  subtle: { count: 110, travel: 0.55, stagger: 0.3, scatter: 0.22, dot: 1.5 },
  // The mark writes itself in about a second. All-purpose.
  default: { count: 150, travel: 0.7, stagger: 0.45, scatter: 0.34, dot: 1.7 },
  // A wider cloud with a longer sweep through the stroke.
  playful: { count: 200, travel: 0.85, stagger: 0.62, scatter: 0.48, dot: 1.9 },
};

type Particle = {
  fromX: number;
  fromY: number;
  toX: number;
  toY: number;
  delay: number;
  progress: number;
  radius: number;
};

/**
 * Glyphs as polylines in a unit box. A ring is a polyline too — one
 * shape of code for both means the arc-length sampler is the only
 * thing that has to be right.
 */
const GLYPHS: Record<"check" | "ring", [number, number][]> = {
  check: [
    [0.2, 0.53],
    [0.42, 0.74],
    [0.8, 0.28],
  ],
  ring: Array.from({ length: 65 }, (_, index) => {
    const angle = (index / 64) * Math.PI * 2 - Math.PI / 2;
    return [0.5 + Math.cos(angle) * 0.32, 0.5 + Math.sin(angle) * 0.32] as [number, number];
  }),
};

export default function ParticleAssemble({
  variant = "default",
  shape = "check",
  runKey = 0,
  count,
  color = "#3FB0A5",
  size = 140,
  onAssembled,
}: ParticleAssembleProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The callback is held in a ref and updated in its own effect, so an
  // inline arrow from the parent cannot restart the assembly.
  const assembledRef = useRef(onAssembled);
  useEffect(() => {
    assembledRef.current = onAssembled;
  }, [onAssembled]);

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

    const config = VARIANTS[variant];
    const total = count ?? config.count;
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const ratio = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.max(1, Math.floor(size * ratio));
    canvas.height = Math.max(1, Math.floor(size * ratio));
    context.setTransform(ratio, 0, 0, ratio, 0, 0);

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

    /**
     * Walk the polyline and hand back N points spaced evenly by
     * distance. Sampling per segment instead would crowd the short
     * segments; sampling a rasterized glyph would need a bitmap.
     */
    const sampleStroke = (points: [number, number][], samples: number) => {
      const lengths: number[] = [];
      let perimeter = 0;
      for (let index = 0; index < points.length - 1; index++) {
        const span = Math.hypot(
          points[index + 1][0] - points[index][0],
          points[index + 1][1] - points[index][1]
        );
        lengths.push(span);
        perimeter += span;
      }

      const spots: { x: number; y: number; along: number }[] = [];
      for (let index = 0; index < samples; index++) {
        const along = (index + 0.5) / samples;
        let travelled = along * perimeter;
        let segment = 0;
        while (segment < lengths.length - 1 && travelled > lengths[segment]) {
          travelled -= lengths[segment];
          segment++;
        }
        const t = lengths[segment] > 0 ? Math.min(1, travelled / lengths[segment]) : 0;
        const [ax, ay] = points[segment];
        const [bx, by] = points[segment + 1];
        // Spread each sample across the width of the stroke, so the
        // result is a drawn line rather than a wire one pixel thick.
        const nx = -(by - ay);
        const ny = bx - ax;
        const norm = Math.hypot(nx, ny) || 1;
        const offset = random(-0.5, 0.5) * 0.055;
        spots.push({
          x: ax + (bx - ax) * t + (nx / norm) * offset,
          y: ay + (by - ay) * t + (ny / norm) * offset,
          along,
        });
      }
      return spots;
    };

    const build = (): Particle[] =>
      sampleStroke(GLYPHS[shape], total).map((spot) => {
        const angle = random(0, Math.PI * 2);
        const distance = size * config.scatter * random(0.55, 1);
        return {
          fromX: spot.x * size + Math.cos(angle) * distance,
          fromY: spot.y * size + Math.sin(angle) * distance,
          toX: spot.x * size,
          toY: spot.y * size,
          // Stroke order: the delay is the position along the path.
          delay: spot.along * config.stagger,
          progress: 0,
          radius: config.dot * random(0.8, 1.2),
        };
      });

    const particles = build();

    const render = () => {
      context.clearRect(0, 0, size, size);
      context.fillStyle = color;
      for (const particle of particles) {
        // Ease out: fast off the mark, settling into place without an
        // overshoot. A particle arriving twice reads as jelly.
        const eased = 1 - Math.pow(1 - particle.progress, 3);
        const x = particle.fromX + (particle.toX - particle.fromX) * eased;
        const y = particle.fromY + (particle.toY - particle.fromY) * eased;
        context.globalAlpha = 0.25 + eased * 0.7;
        context.beginPath();
        context.arc(x, y, particle.radius * (0.75 + eased * 0.25), 0, Math.PI * 2);
        context.fill();
      }
      context.globalAlpha = 1;
    };

    // Reduced motion: the assembled mark, drawn where it lands. The
    // glyph is the message; the converging is only how it arrived.
    if (reduced) {
      for (const particle of particles) particle.progress = 1;
      render();
      return;
    }

    let frame = 0;
    let last = performance.now();
    let elapsed = 0;
    let announced = false;

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

      let settled = true;
      for (const particle of particles) {
        const age = elapsed - particle.delay;
        if (age <= 0) {
          settled = false;
          continue;
        }
        particle.progress = Math.min(1, age / config.travel);
        if (particle.progress < 1) settled = false;
      }

      render();

      if (settled) {
        // Assembled. The mark is static now, so the loop stops rather
        // than redrawing an unchanging frame sixty times a second.
        if (!announced) {
          announced = true;
          assembledRef.current?.();
        }
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [variant, shape, runKey, count, color, size]);

  return (
    <canvas
      ref={canvasRef}
      role="img"
      aria-label={shape === "check" ? "Confirmed" : "Complete"}
      style={{ width: size, height: size, display: "block" }}
    />
  );
}

About this effect

A confirmation that arrives rather than appears: a cloud of points gathers into a checkmark or a ring. The technique is where the targets come from — the glyph is a stroked polyline, and targets are sampled along it by arc length, evenly spaced in distance rather than per segment or by rejection-sampling a bitmap. The finished mark then has constant density and reads instantly. Better still, each particle knows how far along the stroke its target sits, and that same number becomes its delay, so the mark writes itself in stroke order instead of fading in everywhere at once. The loop stops the moment the last particle lands.

Payment confirmedUpload completeTask doneVerification passed

Related effects