Pulse Field
A field of dots that swells and settles as one, for work happening in the background.
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 · Pulse Field
*
* A field of dots that breathes as one body — presence for work that is
* happening in the background while the user gets on with something
* else.
*
* The technique that makes it read as breathing rather than blinking:
* every dot runs the *same* breath, sampled at a time delayed by its
* distance from the centre of the field. The swell is therefore one wave
* crossing the field, and the outer dots finish last, so the grid
* behaves like a single object. Dots pulsing in lockstep read as a
* strobe no matter how slowly you run them — the delay is what turns
* the identical animation into breathing.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PulseFieldProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Dots in the field. Held exactly, whatever the surface's shape. */
count?: number;
/** Dot fill. */
color?: string;
/** Fires once at the crest of each breath, for syncing something to it. */
onBreath?: () => void;
};
type VariantConfig = {
/** Breaths per second. One slow breath is around 0.2. */
rate: number;
/** Fractional swing in size and opacity, 0–1. Never near 1. */
swell: number;
/** How fast the swell crosses the field, px per second. */
waveSpeed: number;
/** Multiplier applied to `count`. */
density: number;
/** Dot radius in px at mid-breath. */
dot: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a change — for a field that sits under real content.
subtle: { rate: 0.13, swell: 0.34, waveSpeed: 300, density: 0.7, dot: 1.4 },
// Reads as alive from across a room without pulling the eye.
default: { rate: 0.19, swell: 0.5, waveSpeed: 220, density: 1, dot: 1.7 },
// A quicker, deeper breath and a slower wave, so the travel is visible.
playful: { rate: 0.28, swell: 0.7, waveSpeed: 160, density: 1.3, dot: 2 },
};
type Dot = {
x: number;
y: number;
/** Seconds this dot lags the centre — the whole trick, precomputed. */
delay: number;
/** Dims toward the corners so the field has no hard border. */
fade: number;
/** Per-dot brightness, so the field has texture at rest. */
tone: number;
};
/** The moment of the wave held when motion is turned off. */
const STILL_AT = 1.6;
export default function PulseField({
variant = "default",
count = 120,
color = "#5B7CFA",
onBreath,
}: PulseFieldProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Callbacks are read through a ref so an inline arrow from the parent
// can't restart the field on every render.
const breathRef = useRef(onBreath);
useEffect(() => {
breathRef.current = onBreath;
}, [onBreath]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const total = Math.max(4, Math.round(count * config.density));
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let ratio = 1;
let dots: Dot[] = [];
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);
};
const build = () => {
// Columns chosen from the aspect ratio so cells stay square-ish on
// a wide banner and on a tall panel alike.
const columns = Math.max(
2,
Math.round(Math.sqrt((total * Math.max(width, 1)) / Math.max(height, 1)))
);
const rows = Math.max(2, Math.ceil(total / columns));
const cellWidth = width / columns;
const cellHeight = height / rows;
const centreX = width / 2;
const centreY = height / 2;
const reach = Math.hypot(centreX, centreY) || 1;
const points: Dot[] = [];
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++) {
// Jitter inside the cell: an exact grid reads as a texture
// swatch, a jittered one reads as a field.
const x = (column + 0.5 + (Math.random() - 0.5) * 0.55) * cellWidth;
const y = (row + 0.5 + (Math.random() - 0.5) * 0.55) * cellHeight;
const distance = Math.hypot(x - centreX, y - centreY);
const edge = distance / reach;
points.push({
x,
y,
delay: distance / config.waveSpeed,
fade: Math.max(0, 1 - Math.pow(Math.max(0, edge - 0.35) / 0.75, 2)),
tone: 0.8 + Math.random() * 0.4,
});
}
}
// Drop the last row's overshoot spread across the field rather than
// leaving a gap along one edge, so `count` is exactly honoured.
const extra = points.length - total;
if (extra > 0) {
const stride = points.length / extra;
for (let index = extra - 1; index >= 0; index--) {
points.splice(Math.floor(index * stride), 1);
}
}
dots = points;
};
const drawFrame = (elapsed: number) => {
context.clearRect(0, 0, width, height);
context.fillStyle = color;
for (const dot of dots) {
// Same breath for every dot; only the sampling time differs.
const breath =
0.5 + 0.5 * Math.sin((elapsed - dot.delay) * config.rate * Math.PI * 2);
const radius = config.dot * (1 - config.swell * 0.45 + config.swell * 0.9 * breath);
const alpha = dot.fade * dot.tone * (0.34 + config.swell * (breath - 0.5) * 0.8);
if (alpha <= 0.012 || radius <= 0.06) continue;
context.globalAlpha = Math.min(0.9, alpha);
context.beginPath();
context.arc(dot.x, dot.y, radius, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
};
resize();
build();
// Reduced motion: one moment of the wave, held. Because each dot is
// sampled at its own delay the frozen field still has structure —
// a swell near the centre, smaller dots at the rim — so it reads as
// a field rather than a flat dot grid.
if (reduced) {
drawFrame(STILL_AT);
const onResizeStill = () => {
resize();
build();
drawFrame(STILL_AT);
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let last = performance.now();
let elapsed = 0;
let lastCrest = -1;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
// The crest sits a quarter cycle after the phase origin.
const crest = Math.floor(elapsed * config.rate + 0.25);
if (crest !== lastCrest) {
if (lastCrest >= 0) breathRef.current?.();
lastCrest = crest;
}
drawFrame(elapsed);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
build();
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block", pointerEvents: "none" }}
/>
);
}About this effect
Presence for a job the user is not watching — a sync, an index, an agent still working while they type somewhere else. Every dot runs the same breath, but sampled at a time delayed by its distance from the centre, so the swell travels outward as a single wave and the rim finishes last. That delay is the whole difference: dots pulsing in lockstep read as a strobe however slowly you run them, while a travelling delay reads as one thing inhaling. Size and opacity move together and never approach full contrast, so the field stays under the content it sits behind.