Queue Stream
Items flowing along a route, where the gap between them is the throughput.
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.
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.