← All particles

Load Swarm

Particles orbiting a centre, tightening and speeding up as the work approaches done.

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

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

/**
 * Vibary · Load Swarm
 *
 * Particles orbiting a centre, drawing in as the work approaches done.
 *
 * The technique that makes progress legible without a number: angular
 * speed rises as the orbit tightens. A shrinking ring at constant speed
 * just looks smaller — the same picture as a ring that is far from
 * finished but drawn small. Tie speed to 1/radius and the swarm visibly
 * accelerates on the way in, so "nearly there" is carried by the motion
 * rather than by size alone. It is a softened version of what a real
 * orbit does; the exponent is tuned so the finish quickens without
 * turning into a blur.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; drive it with `progress`, tune with `count`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type LoadSwarmProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** How far along the work is, 0–1. Drive this from real progress. */
  progress?: number;
  /** Particles in the swarm. */
  count?: number;
  /** Particle fill. */
  color?: string;
  /** Fires once, the first time the swarm has drawn all the way in. */
  onSettled?: () => void;
};

type VariantConfig = {
  /** Multiplier applied to `count`. */
  density: number;
  /** Turns per second at the widest orbit. */
  spin: number;
  /** Widest orbit as a fraction of the surface's half-minimum dimension. */
  outer: number;
  /** Particle radius in px at the front of the orbit. */
  dot: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A thin, unhurried ring — for a wait the user is not staring at.
  subtle: { density: 0.7, spin: 0.2, outer: 0.78, dot: 1.5 },
  // Reads as "working" from a metre away. All-purpose.
  default: { density: 1, spin: 0.3, outer: 0.86, dot: 1.8 },
  // Denser and quicker, and it swings wider before it closes.
  playful: { density: 1.35, spin: 0.42, outer: 0.95, dot: 2.1 },
};

type Mote = {
  /** Where it currently is in its own orbit. */
  angle: number;
  /** Inclination of that orbit's plane, which is what gives depth. */
  tilt: number;
  /** Radial offset from the mean orbit, −1 to 1. Narrows to nothing. */
  jitter: number;
  /** Per-mote speed multiplier, so the ring never turns as one disc. */
  speed: number;
  /** Per-mote size multiplier. */
  size: number;
};

/** How fast the drawn progress catches up to the prop, in units/second. */
const FOLLOW = 1.6;

export default function LoadSwarm({
  variant = "default",
  progress = 0,
  count = 64,
  color = "#6C7CF2",
  onSettled,
}: LoadSwarmProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // Live values are read through refs so the swarm is never rebuilt
  // mid-flight by a parent re-render.
  const progressRef = useRef(progress);
  useEffect(() => {
    progressRef.current = progress;
  }, [progress]);
  const settledRef = useRef(onSettled);
  useEffect(() => {
    settledRef.current = onSettled;
  }, [onSettled]);

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

    const config = VARIANTS[variant];
    const total = Math.max(6, Math.round(count * config.density));
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;
    let ratio = 1;
    let outer = 1;

    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);
      outer = Math.max(8, (Math.min(width, height) / 2) * config.outer);
    };

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

    const motes: Mote[] = Array.from({ length: total }, () => ({
      angle: random(0, Math.PI * 2),
      tilt: random(-0.75, 0.75),
      jitter: random(-1, 1),
      speed: random(0.85, 1.15),
      size: random(0.75, 1.25),
    }));

    /** Mean orbit radius at a given progress, plus this mote's offset. */
    const radiusFor = (mote: Mote, shown: number) =>
      outer * (1 - shown * 0.7) * (1 + mote.jitter * 0.3 * (1 - shown));

    const drawFrame = (shown: number) => {
      context.clearRect(0, 0, width, height);
      const centreX = width / 2;
      const centreY = height / 2;
      context.fillStyle = color;

      for (const mote of motes) {
        const radius = radiusFor(mote, shown);
        const orbitY = Math.sin(mote.angle) * radius;
        const x = Math.cos(mote.angle) * radius;
        const y = orbitY * Math.cos(mote.tilt);
        const z = orbitY * Math.sin(mote.tilt);
        // One fixed perspective divide: the back of each orbit sits
        // slightly smaller and dimmer than the front.
        const depth = 1 / (1 + (z / (outer * 4)) * 0.7);

        context.globalAlpha = Math.min(0.95, (0.32 + depth * 0.48) * (0.78 + shown * 0.3));
        context.beginPath();
        context.arc(
          centreX + x * depth,
          centreY + y * depth,
          Math.max(0.4, config.dot * mote.size * depth),
          0,
          Math.PI * 2
        );
        context.fill();
      }
      context.globalAlpha = 1;
    };

    resize();

    let frame = 0;
    let last = performance.now();
    let shown = Math.min(1, Math.max(0, progressRef.current));
    let announced = false;

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

      // Ease toward the reported progress so a value that arrives in
      // jumps still reads as one continuous closing-in.
      const target = Math.min(1, Math.max(0, progressRef.current));
      shown += (target - shown) * Math.min(1, FOLLOW * delta);
      if (Math.abs(target - shown) < 0.002) shown = target;

      if (!announced && shown >= 0.999) {
        announced = true;
        settledRef.current?.();
      } else if (announced && target < 0.9) {
        announced = false;
      }

      if (!reduced) {
        for (const mote of motes) {
          const radius = Math.max(1, radiusFor(mote, shown));
          // Tighter orbit, faster sweep — the acceleration is the signal.
          const rate = Math.pow(outer / radius, 0.7);
          mote.angle += config.spin * rate * mote.speed * Math.PI * 2 * delta;
        }
      }

      drawFrame(shown);
      frame = requestAnimationFrame(tick);
    };

    // Reduced motion: the orbits hold still, but the swarm still draws
    // in as progress advances. The contraction is the information, so
    // removing it would leave a ring that lies about how far along the
    // work is; only the decorative sweep is dropped.
    frame = requestAnimationFrame(tick);
    window.addEventListener("resize", resize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", resize);
    };
  }, [variant, count, color]);

  return (
    <canvas
      ref={canvasRef}
      role="img"
      aria-label={`Working, ${Math.round(Math.min(1, Math.max(0, progress)) * 100)} percent complete`}
      style={{ width: "100%", height: "100%", display: "block", pointerEvents: "none" }}
    />
  );
}

About this effect

A progress indicator for a wait with a known end — an import, an upload, a model finishing a pass. The orbits draw inward as progress advances, but the detail that makes progress legible is that angular speed rises as the radius falls: a shrinking ring at constant speed just looks smaller, while a ring that quickens as it closes reads as nearly finished. Orbit planes are inclined at different angles so the swarm has depth rather than sitting flat, and the drawn progress eases toward the reported value so a number that arrives in jumps still reads as one continuous closing-in.

File import runningUpload with known progressModel pass completingCheckout processing step

Related effects