← All particles

Health Cloud

A cloud whose tightness and colour report a value, with no number to read.

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

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

/**
 * Vibary · Health Cloud
 *
 * A cloud that reports a value — a service's health, a signal quality, a
 * battery of checks — without printing a number.
 *
 * The technique that makes it readable: the value drives *cohesion*, not
 * colour. A healthy value holds the particles in a tight, slow, dense
 * core; as it degrades they scatter outward and churn. Loss of cohesion
 * is legible peripherally and at a glance, before anyone parses a hue,
 * and it survives colour blindness, a greyscale screenshot and a dark
 * room. Colour rides along as a second, redundant channel rather than
 * as the message.
 *
 * Supporting detail: the particle count never changes, so a state change
 * is the cloud loosening rather than particles appearing and vanishing.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; drive it with `value`, tune with `count`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type HealthCloudProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The reading, 0–1. 1 is healthy, 0.5 is warning, 0 is bad. */
  value?: number;
  /** Particles in the cloud. Constant across every value, by design. */
  count?: number;
  /** The three stops the colour ramp passes through. */
  colors?: { good: string; warning: string; bad: string };
};

type VariantConfig = {
  /** Multiplier applied to `count`. */
  density: number;
  /** Particle radius in px. */
  dot: number;
  /** Multiplier on drift speed and amplitude. */
  churn: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A quiet readout for a dashboard with many of them.
  subtle: { density: 0.7, dot: 1.5, churn: 0.75 },
  // Reads across a room. All-purpose.
  default: { density: 1, dot: 1.8, churn: 1 },
  // Denser and more restless, for a single hero status.
  playful: { density: 1.35, dot: 2.1, churn: 1.35 },
};

const DEFAULT_COLORS = { good: "#3E9F6E", warning: "#D79A34", bad: "#D2543F" };

type Mote = {
  /** Radial position in the cloud, 0 at the core to 1 at the rim. */
  seed: number;
  angle: number;
  phase: number;
  /** Per-mote drift rate, so nothing moves in lockstep. */
  rate: number;
  size: number;
};

type Rgb = { r: number; g: number; b: number };

function parseHex(hex: string): Rgb {
  const value = hex.replace("#", "");
  const full =
    value.length === 3
      ? value
          .split("")
          .map((char) => char + char)
          .join("")
      : value;
  const number = parseInt(full, 16);
  return { r: (number >> 16) & 255, g: (number >> 8) & 255, b: number & 255 };
}

function mixRgb(from: Rgb, to: Rgb, amount: number): Rgb {
  return {
    r: Math.round(from.r + (to.r - from.r) * amount),
    g: Math.round(from.g + (to.g - from.g) * amount),
    b: Math.round(from.b + (to.b - from.b) * amount),
  };
}

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

export default function HealthCloud({
  variant = "default",
  value = 0.86,
  count = 90,
  colors = DEFAULT_COLORS,
}: HealthCloudProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // Live values are read through refs so a parent re-render never
  // rebuilds the cloud — the point is that the same particles move.
  const valueRef = useRef(value);
  useEffect(() => {
    valueRef.current = value;
  }, [value]);

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

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

    const good = parseHex(colors.good);
    const warning = parseHex(colors.warning);
    const bad = parseHex(colors.bad);
    const colourAt = (reading: number) =>
      reading >= 0.5
        ? mixRgb(warning, good, (reading - 0.5) * 2)
        : mixRgb(bad, warning, reading * 2);

    let width = 0;
    let height = 0;
    let ratio = 1;
    let reach = 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);
      reach = Math.max(8, Math.min(width, height) / 2);
    };

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

    const motes: Mote[] = Array.from({ length: total }, () => ({
      // Square root keeps the disc evenly covered instead of crowding
      // everything into the middle.
      seed: Math.sqrt(Math.random()),
      angle: random(0, Math.PI * 2),
      phase: random(0, Math.PI * 2),
      rate: random(0.7, 1.35),
      size: random(0.7, 1.3),
    }));

    const drawFrame = (shown: number, elapsed: number) => {
      context.clearRect(0, 0, width, height);
      const centreX = width / 2;
      const centreY = height / 2;
      const health = Math.min(1, Math.max(0, shown));
      const strain = 1 - health;

      // The one dial. Everything below reads from it.
      const spread = reach * (0.2 + strain * 0.72);
      const amplitude = reach * (0.02 + strain * 0.15) * config.churn;
      const speed = (0.25 + strain * 1.5) * config.churn;
      // A slow common churn that only really shows up when things are bad.
      const swirl = elapsed * 0.06 * (0.35 + strain * 1.4);

      // One colour for the whole frame; only opacity varies per mote, so
      // there is a single style change rather than one per particle.
      const colour = colourAt(health);
      context.fillStyle = `rgb(${colour.r}, ${colour.g}, ${colour.b})`;

      for (const mote of motes) {
        const base = spread * mote.seed;
        const wobble = Math.sin(elapsed * speed * mote.rate + mote.phase);
        const sway = Math.cos(elapsed * speed * 0.7 * mote.rate + mote.phase * 1.7);
        const radius = Math.max(0, base + amplitude * wobble);
        const angle = mote.angle + swirl + (amplitude / reach) * sway;

        // Brighter in the core, so a tight cloud also reads as a solid
        // one and a scattered cloud reads as thin.
        context.globalAlpha = 0.9 - 0.42 * mote.seed;
        context.beginPath();
        context.arc(
          centreX + Math.cos(angle) * radius,
          // Slightly squashed, so it sits as a cloud rather than a disc.
          centreY + Math.sin(angle) * radius * 0.88,
          config.dot * mote.size,
          0,
          Math.PI * 2
        );
        context.fill();
      }
      context.globalAlpha = 1;
    };

    resize();

    let frame = 0;
    let last = performance.now();
    let elapsed = 0;
    let shown = Math.min(1, Math.max(0, valueRef.current));
    let drawn = -1;

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

      // Ease toward the reading so a metric that steps reads as the
      // cloud loosening rather than as a jump cut.
      const target = Math.min(1, Math.max(0, valueRef.current));
      shown += (target - shown) * Math.min(1, FOLLOW * delta);
      if (Math.abs(target - shown) < 0.002) shown = target;

      if (reduced) {
        // Reduced motion: no drift, but the spread and colour still
        // follow the reading — that is the information, not decoration.
        // Redraw only when the reading has actually moved.
        if (Math.abs(shown - drawn) > 0.001) {
          drawn = shown;
          drawFrame(shown, 0);
        }
      } else {
        elapsed += delta;
        drawFrame(shown, elapsed);
      }

      frame = requestAnimationFrame(tick);
    };

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

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

  const reading = Math.min(1, Math.max(0, value));
  return (
    <canvas
      ref={canvasRef}
      role="img"
      aria-label={
        reading >= 0.66
          ? "Health: good"
          : reading >= 0.33
            ? "Health: degraded"
            : "Health: critical"
      }
      style={{ width: "100%", height: "100%", display: "block", pointerEvents: "none" }}
    />
  );
}

About this effect

A status readout for a thing that is fine, or nearly fine, or not fine — a service, a connection, a set of checks. The value drives cohesion rather than colour: healthy holds the particles in a tight slow core, and as the reading falls they scatter outward and churn. Loss of cohesion is what the eye catches from across a dashboard, before anyone parses a hue, and it still works in greyscale, at a glance and for a colour-blind reader; the green-to-red ramp rides along as a second, redundant channel. The particle count never changes, so a state change reads as the same cloud loosening rather than as particles appearing and vanishing, and the drawn value eases toward the reported one so a metric that steps still moves continuously.

Service health tileConnection quality readoutData freshness indicatorModel confidence display

Related effects