Health Cloud
A cloud whose tightness and colour report a value, with no number to read.
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 · 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.