Heartbeat Dots
A row of dots beating in sequence, quicker and tighter as the system strains.
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 · Heartbeat Dots
*
* A row of dots that beats along its length, quicker and tighter as the
* system it reports on comes under strain.
*
* The technique: nothing here is scheduled. There is one phase
* accumulator, advanced each frame by delta / period, and a dot's
* brightness is a pure function of that phase minus its own share of the
* lag. A change in load therefore changes the *derivative* of the phase
* and never the phase itself, so a metric that jumps from calm to
* critical between two frames simply speeds the beat up — it cannot
* stutter, skip a dot, or fire the same beat twice. A cascade of timers
* restarted on each reading does all three, and does them exactly when
* the reading is changing fastest, which is when it matters most.
*
* The same choice buys the second property. The lag between neighbouring
* dots is a fraction of the period rather than a number of milliseconds,
* so the wave always crosses the row in less than one beat and there is
* never more than one beat on the row at any rate. Fix the lag in
* seconds and a fast enough beat laps itself, and the row stops reading
* as a sequence precisely when it is racing. The lag is clamped against
* the dot count for the same reason, so the invariant survives someone
* asking for twenty dots.
*
* Rate is the only channel: there is no colour ramp, so this reads the
* same in greyscale and for a colour-blind viewer. Pair it with a label.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `load`, `dots`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type HeartbeatDotsProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Strain, 0 (idle) to 1 (at the limit). Drives rate and tightness. */
load?: number;
/** Dots in the row. */
dots?: number;
/** Dot colour. Defaults to the inherited text colour. */
color?: string;
};
type VariantConfig = {
/** Seconds per beat at load 0. */
restPeriod: number;
/** Seconds per beat at load 1. */
strainPeriod: number;
/** Lag between neighbours at load 0, as a fraction of the period. */
restLag: number;
/** Lag between neighbours at load 1. Smaller: the row tightens. */
strainLag: number;
/** Dot radius in px at rest. */
dot: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A slow pulse that never gets urgent, for a tile you glance at.
subtle: { restPeriod: 1.45, strainPeriod: 0.62, restLag: 0.075, strainLag: 0.032, dot: 2.6 },
// Reads as a pulse rate without becoming an alarm. All-purpose.
default: { restPeriod: 1.15, strainPeriod: 0.42, restLag: 0.085, strainLag: 0.028, dot: 3 },
// A wider swing between calm and strained, and a livelier beat.
playful: { restPeriod: 0.9, strainPeriod: 0.3, restLag: 0.088, strainLag: 0.024, dot: 3.4 },
};
/** Where the second, smaller lobe of the beat ends, in phase units. */
const BEAT_END = 0.24;
/** Seconds for the drawn load to catch a reported one that steps. */
const EASE = 0.45;
/**
* One beat: a sharp first lobe and a smaller second one a moment later.
* Both are expressed as fractions of a period, so the shape of a beat is
* the same whether it lasts a second or a third of one.
*/
function beatAt(u: number) {
if (u < 0.1) {
return u < 0.022 ? u / 0.022 : Math.pow(1 - (u - 0.022) / 0.078, 1.7);
}
if (u >= 0.13 && u < BEAT_END) {
const v = (u - 0.13) / (BEAT_END - 0.13);
return 0.42 * (v < 0.2 ? v / 0.2 : Math.pow(1 - (v - 0.2) / 0.8, 1.6));
}
return 0;
}
export default function HeartbeatDots({
variant = "default",
load = 0.25,
dots = 9,
color,
}: HeartbeatDotsProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// The loop reads the load through a ref so a new reading never tears
// down the field — and the ref is written in an effect, not during
// render, which would be a side effect mid-render.
const loadRef = useRef(load);
useEffect(() => {
loadRef.current = load;
}, [load]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const count = Math.max(2, Math.round(dots));
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ink = color ?? getComputedStyle(canvas).color ?? "#888888";
// The invariant, defended against a caller's dot count: the whole
// wave — the lag across the row plus one beat — has to fit inside a
// single period, or two beats share the row and the sequence stops
// reading as a sequence.
const headroom = (1 - BEAT_END - 0.06) / (count - 1);
const restLag = Math.min(config.restLag, headroom);
const strainLag = Math.min(config.strainLag, headroom);
let width = 0;
let height = 0;
let gap = 0;
let radius = config.dot;
let left = 0;
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);
const margin = Math.min(width * 0.08, 18);
gap = (width - margin * 2) / (count - 1);
left = margin;
radius = Math.min(config.dot, gap * 0.34, height * 0.22);
};
resize();
const render = (phase: number, lag: number) => {
context.clearRect(0, 0, width, height);
const centreY = height / 2;
for (let index = 0; index < count; index++) {
const u = phase - index * lag;
const brightness = beatAt(u - Math.floor(u));
const x = left + index * gap;
// The dot never disappears: the row is the system, and it is
// still there between beats.
context.fillStyle = ink;
context.shadowColor = ink;
context.shadowBlur = brightness > 0.02 ? radius * 3.4 * brightness : 0;
context.globalAlpha = 0.2 + brightness * 0.75;
context.beginPath();
context.arc(x, centreY, radius * (1 + brightness * 0.55), 0, Math.PI * 2);
context.fill();
}
context.shadowBlur = 0;
context.globalAlpha = 1;
};
// Reduced motion: the wave held part-way along the row. This still
// carries the reading, because the lag is what tightens under strain
// — at rest the beat covers about three dots, and at the limit it
// covers most of the row, so a frozen frame is wide or narrow for
// the same reason the moving one is. It is repainted when the value
// changes and at no other time; nothing here animates.
if (reduced) {
const still = () => {
const value = Math.min(1, Math.max(0, loadRef.current));
const lag = restLag + (strainLag - restLag) * value;
render((count - 1) * lag * 0.55 + 0.04, lag);
return value;
};
let shownStill = still();
const poll = window.setInterval(() => {
if (Math.abs(loadRef.current - shownStill) > 0.01) shownStill = still();
}, 250);
const onResizeStill = () => {
resize();
shownStill = still();
};
window.addEventListener("resize", onResizeStill);
return () => {
window.clearInterval(poll);
window.removeEventListener("resize", onResizeStill);
};
}
let frame = 0;
let phase = 0;
let shown = Math.min(1, Math.max(0, loadRef.current));
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
// The drawn load chases the reported one, so a metric that arrives
// in steps still changes the beat continuously.
const target = Math.min(1, Math.max(0, loadRef.current));
shown += (target - shown) * Math.min(1, delta / EASE);
const period = config.restPeriod + (config.strainPeriod - config.restPeriod) * shown;
const lag = restLag + (strainLag - restLag) * shown;
// The one line that matters: the rate is integrated, so the phase
// is continuous no matter how violently the period moves.
phase += delta / period;
if (phase > 1e6) phase -= 1e6;
render(phase, lag);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => resize();
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, dots, color]);
return (
<canvas
ref={canvasRef}
role="img"
aria-label={
load < 0.3 ? "System steady" : load < 0.7 ? "System busy" : "System under load"
}
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
A pulse for something that is running — a worker pool, an ingest queue, a live connection — where the rate is the reading. Nothing in it is scheduled: one phase accumulator advances by delta over the period each frame, and every dot's brightness is a function of that phase minus its own share of the lag. A change in load therefore changes the derivative of the phase and never the phase itself, so a metric that jumps between two frames speeds the beat up continuously instead of stuttering, skipping a dot or firing the same beat twice — which is what a cascade of timers restarted on each reading does, exactly when the reading is moving fastest. The lag between neighbours is a fraction of the period rather than a number of milliseconds, which is what guarantees there is never more than one beat on the row at any rate; fix it in seconds and a fast enough beat laps itself. Rate is the only channel — there is no colour ramp — so it reads the same in greyscale and beside a label.