← All particles

Heartbeat Dots

A row of dots beating in sequence, quicker and tighter as the system strains.

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

248 lines · react only
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.

Service health tileWorker pool loadLive connection heartbeatQueue pressure readout

Related effects