← All particles

Queue Stream

Items flowing along a route, where the gap between them is the throughput.

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

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

/**
 * Vibary · Queue Stream
 *
 * Items flowing along a route, where the gap between them is the
 * throughput — a pipeline, a queue draining, traffic between two
 * services.
 *
 * The technique that makes the spacing mean something: particles are
 * emitted on a *clock* and then travel at a constant speed along the
 * path's arc length. Nothing positions them; the gap between two
 * particles is literally the time between two arrivals, so a busy
 * stream packs tight and a quiet one strings out, with no separate
 * "spacing" parameter to keep in sync. Arc-length travel is the other
 * half of it — step a Bézier by its `t` and particles race down the
 * straights and crawl through the corners, which reads as a broken
 * animation rather than as flow.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; drive it with `rate`, route it with `path`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type QueueStreamProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Items entering per second. This is the value the spacing reports. */
  rate?: number;
  /** Four cubic Bézier control points, as fractions of the surface. */
  path?: { x: number; y: number }[];
  /** Particle and rail fill. */
  color?: string;
  /** Fires as each item reaches the end of the route. */
  onDelivered?: () => void;
};

type VariantConfig = {
  /** Seconds for one item to cross the whole route. */
  traverse: number;
  /** Particle radius in px. */
  dot: number;
  /** Opacity of the route behind the particles. */
  rail: number;
  /** Multiplier applied to `rate`. */
  density: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Slower and sparser — a background pipeline, not the subject.
  subtle: { traverse: 4.2, dot: 2, rail: 0.07, density: 0.7 },
  // Reads as flow at a glance. All-purpose.
  default: { traverse: 3.2, dot: 2.4, rail: 0.1, density: 1 },
  // Quicker and fuller, for a hero throughput panel.
  playful: { traverse: 2.4, dot: 2.9, rail: 0.13, density: 1.35 },
};

/** A gentle S from the left edge to the right, in surface fractions. */
const DEFAULT_PATH = [
  { x: 0.04, y: 0.7 },
  { x: 0.36, y: 0.7 },
  { x: 0.64, y: 0.3 },
  { x: 0.96, y: 0.3 },
];

type Sample = { x: number; y: number; at: number };
type Item = { distance: number; size: number };

/** Samples along the curve. Enough that the arc-length error is invisible. */
const STEPS = 160;

export default function QueueStream({
  variant = "default",
  rate = 6,
  path = DEFAULT_PATH,
  color = "#4EA8A0",
  onDelivered,
}: QueueStreamProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // Live values are read through refs, so changing the rate speeds the
  // stream up instead of restarting it.
  const rateRef = useRef(rate);
  useEffect(() => {
    rateRef.current = rate;
  }, [rate]);
  const deliveredRef = useRef(onDelivered);
  useEffect(() => {
    deliveredRef.current = onDelivered;
  }, [onDelivered]);

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

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

    let width = 0;
    let height = 0;
    let ratio = 1;
    let samples: Sample[] = [];
    let length = 1;

    const controlAt = (index: number) => ({
      x: path[index].x * width,
      y: path[index].y * height,
    });

    const build = () => {
      const [p0, p1, p2, p3] = [0, 1, 2, 3].map(controlAt);
      samples = [];
      let travelled = 0;
      let previousX = 0;
      let previousY = 0;
      for (let index = 0; index <= STEPS; index++) {
        const t = index / STEPS;
        const inverse = 1 - t;
        // Cubic Bézier, expanded — cheaper than three lerp passes and
        // this runs once per resize, not per frame.
        const x =
          inverse * inverse * inverse * p0.x +
          3 * inverse * inverse * t * p1.x +
          3 * inverse * t * t * p2.x +
          t * t * t * p3.x;
        const y =
          inverse * inverse * inverse * p0.y +
          3 * inverse * inverse * t * p1.y +
          3 * inverse * t * t * p2.y +
          t * t * t * p3.y;
        if (index > 0) travelled += Math.hypot(x - previousX, y - previousY);
        samples.push({ x, y, at: travelled });
        previousX = x;
        previousY = y;
      }
      length = Math.max(1, travelled);
    };

    /** Position at a distance along the curve — the arc-length lookup. */
    const pointAt = (distance: number) => {
      let low = 0;
      let high = samples.length - 1;
      while (low < high - 1) {
        const middle = (low + high) >> 1;
        if (samples[middle].at < distance) low = middle;
        else high = middle;
      }
      const from = samples[low];
      const to = samples[high];
      const span = to.at - from.at || 1;
      const amount = Math.min(1, Math.max(0, (distance - from.at) / span));
      return {
        x: from.x + (to.x - from.x) * amount,
        y: from.y + (to.y - from.y) * amount,
      };
    };

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

    const drawFrame = (items: Item[]) => {
      context.clearRect(0, 0, width, height);
      const [p0, p1, p2, p3] = [0, 1, 2, 3].map(controlAt);

      // The route, faint: without it the particles look like they are
      // drifting rather than following something.
      context.globalAlpha = config.rail;
      context.strokeStyle = color;
      context.fillStyle = color;
      context.lineWidth = config.dot * 1.6;
      context.lineCap = "round";
      context.beginPath();
      context.moveTo(p0.x, p0.y);
      context.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
      context.stroke();

      // The ends, so the stream reads as running between two places.
      context.globalAlpha = Math.min(0.5, config.rail * 3.4);
      for (const end of [p0, p3]) {
        context.beginPath();
        context.arc(end.x, end.y, config.dot * 2.1, 0, Math.PI * 2);
        context.fill();
      }

      context.fillStyle = color;
      for (const item of items) {
        const along = item.distance / length;
        // Fade at both ends so items enter and leave rather than pop.
        const edge = Math.min(1, Math.min(along, 1 - along) / 0.09);
        if (edge <= 0) continue;
        const point = pointAt(item.distance);
        context.globalAlpha = 0.35 + edge * 0.5;
        context.beginPath();
        context.arc(point.x, point.y, config.dot * item.size, 0, Math.PI * 2);
        context.fill();
      }
      context.globalAlpha = 1;
    };

    resize();

    const speedFor = () => length / config.traverse;
    const gapFor = () => {
      const perSecond = Math.max(0.15, rateRef.current * config.density);
      return speedFor() / perSecond;
    };

    // Reduced motion: the stream held still. Spacing is the whole
    // readout and it survives a frozen frame exactly — a packed rail is
    // a busy queue whether or not anything is moving.
    if (reduced) {
      let drawnGap = -1;
      const still = () => {
        const gap = gapFor();
        drawnGap = gap;
        const items: Item[] = [];
        for (let distance = gap * 0.5; distance < length; distance += gap) {
          items.push({ distance, size: 1 });
        }
        drawFrame(items);
      };
      still();

      let frame = 0;
      const watch = () => {
        // Redraw only when the reported rate has actually changed.
        if (Math.abs(gapFor() - drawnGap) > 0.5) still();
        frame = requestAnimationFrame(watch);
      };
      frame = requestAnimationFrame(watch);

      const onResizeStill = () => {
        resize();
        still();
      };
      window.addEventListener("resize", onResizeStill);
      return () => {
        cancelAnimationFrame(frame);
        window.removeEventListener("resize", onResizeStill);
      };
    }

    let items: Item[] = [];
    let sinceEmit = 0;
    let frame = 0;
    let last = performance.now();

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

      const speed = speedFor();
      for (const item of items) item.distance += speed * delta;

      const arrived = items.filter((item) => item.distance >= length);
      if (arrived.length > 0) {
        items = items.filter((item) => item.distance < length);
        for (let index = 0; index < arrived.length; index++) deliveredRef.current?.();
      }

      // Emit on the clock, then back-date the new item by however long
      // ago in this frame it was due. Spacing stays exact at any frame
      // rate, which is what makes it a readable measure.
      sinceEmit += delta;
      const interval = 1 / Math.max(0.15, rateRef.current * config.density);
      while (sinceEmit >= interval) {
        sinceEmit -= interval;
        items.push({ distance: speed * sinceEmit, size: 0.85 + Math.random() * 0.3 });
      }

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

    frame = requestAnimationFrame(tick);
    window.addEventListener("resize", resize);

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

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

About this effect

Flow between two places — a queue draining, an ingestion pipeline, traffic between two services. Particles are emitted on a clock and then travel at a constant speed along the route's arc length, so nothing positions them: the gap between two particles is literally the time between two arrivals, and a busy stream packs tight while a quiet one strings out. Arc-length travel is the other half of it, because stepping a Bézier by its parameter makes particles race down the straights and crawl through the corners, which reads as a broken animation rather than as flow. Emission is back-dated within the frame so spacing stays exact at any frame rate.

Queue throughput panelIngestion pipeline statusService-to-service trafficBackground job flow

Related effects