← All particles
Dust Motes
Fine specks adrift in a room, brightening as they cross a shaft of light.
ambientcalmquietwarm120 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.
250 lines · react only
import { useEffect, useRef } from "react";
/**
* Vibary · Dust Motes
*
* Fine specks adrift in a still room, with a shaft of light falling
* across them. The technique that makes it read: a mote's brightness is
* derived from its perpendicular distance to the beam's axis, so the
* same speck is nearly invisible, then bright, then nearly invisible
* again as it drifts through. Painting a glow on top of an evenly lit
* field never reads as one room — the specks in the light have to be
* the specks that were dim a second ago.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `angle`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type DustMotesProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** How many specks are in the air. Overrides the variant's density. */
count?: number;
/**
* Speck color. Warm, because light is — and pitched mid-tone rather
* than white so the field survives on a pale surface too.
*/
color?: string;
/** Lean of the shaft away from vertical, in degrees; positive tips right. */
angle?: number;
/** How visible the shaft itself is, 0–1. Above ~0.2 it stops being light. */
beamStrength?: number;
};
type VariantConfig = {
/** Specks in the air at this setting. */
count: number;
/** Vertical drift in px per second, before per-mote variation. */
drift: number;
/** Lateral wander amplitude in px per second. */
wander: number;
/** Half-width of the shaft at its far end, as a fraction of the width. */
beam: number;
/** Largest speck radius in px. */
size: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Air that has not been disturbed in hours.
subtle: { count: 80, drift: 5, wander: 2.5, beam: 0.3, size: 1.3 },
// Someone walked past a minute ago. All-purpose.
default: { count: 120, drift: 8, wander: 4.5, beam: 0.34, size: 1.6 },
// A window just opened: the column stirs and specks cross it faster.
playful: { count: 165, drift: 14, wander: 8, beam: 0.4, size: 1.9 },
};
type Mote = {
x: number;
y: number;
/** Vertical speed; a quarter of them rise, as dust in a warm room does. */
speed: number;
radius: number;
/** Phase and rate of this speck's own meander, so none move in lockstep. */
phase: number;
wanderRate: number;
};
export default function DustMotes({
variant = "default",
count,
color = "#E9CB94",
angle = 20,
beamStrength = 0.13,
}: DustMotesProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const total = count ?? config.count;
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let ratio = 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);
};
resize();
const random = (min: number, max: number) => min + Math.random() * (max - min);
const spawn = (): Mote => ({
x: random(-10, width + 10),
y: random(-10, height + 10),
speed: config.drift * random(0.4, 1.4) * (Math.random() < 0.25 ? -1 : 1),
radius: random(0.45, config.size),
phase: random(0, Math.PI * 2),
wanderRate: random(0.18, 0.5),
});
let motes = Array.from({ length: total }, spawn);
const radians = (angle * Math.PI) / 180;
// Unit vector pointing down the shaft. Everything else — the wedge,
// the lit test, the fade with distance — is expressed against it.
const dirX = Math.sin(radians);
const dirY = Math.cos(radians);
/** How strongly the light falls on a point, 0–1. */
const litness = (x: number, y: number) => {
const sourceX = width * 0.3;
const sourceY = -height * 0.2;
const span = height * 1.5;
const along = (x - sourceX) * dirX + (y - sourceY) * dirY;
const across = (x - sourceX) * dirY - (y - sourceY) * dirX;
const reach = Math.max(0, Math.min(1, along / span));
// The shaft widens as it travels, so the edge test has to widen too.
const halfWidth = width * config.beam * (0.5 + 0.5 * reach);
const edge = 1 - Math.min(1, Math.abs(across) / halfWidth);
if (edge <= 0) return 0;
// Smoothstep: a linear falloff leaves a visible seam at the edge.
return edge * edge * (3 - 2 * edge) * (1 - reach * 0.7);
};
const drawBeam = () => {
const sourceX = width * 0.3;
const sourceY = -height * 0.2;
const span = height * 1.5;
const farHalf = width * config.beam;
const nearHalf = farHalf * 0.5;
context.save();
context.translate(sourceX, sourceY);
// Rotate so local +y runs down the shaft; the sign flips because
// canvas rotation turns local (0,1) into (-sin θ, cos θ).
context.rotate(-radians);
const gradient = context.createLinearGradient(-farHalf, 0, farHalf, 0);
// Fully transparent stops carry the beam's own color rather than
// `transparent`, which interpolates through gray and leaves a fringe.
gradient.addColorStop(0, "rgba(255, 222, 158, 0)");
gradient.addColorStop(0.5, `rgba(255, 222, 158, ${beamStrength})`);
gradient.addColorStop(1, "rgba(255, 222, 158, 0)");
context.fillStyle = gradient;
context.beginPath();
context.moveTo(-nearHalf, 0);
context.lineTo(nearHalf, 0);
context.lineTo(farHalf, span);
context.lineTo(-farHalf, span);
context.closePath();
context.fill();
// Erase toward the far end so the shaft loses itself in the room.
// Safe as a first pass: nothing but the wedge has been drawn yet.
const fade = context.createLinearGradient(0, span * 0.25, 0, span);
fade.addColorStop(0, "rgba(0, 0, 0, 0)");
fade.addColorStop(1, "rgba(0, 0, 0, 1)");
context.globalCompositeOperation = "destination-out";
context.fillStyle = fade;
context.fillRect(-farHalf, span * 0.25, farHalf * 2, span * 0.75);
context.globalCompositeOperation = "source-over";
context.restore();
};
const render = () => {
context.clearRect(0, 0, width, height);
drawBeam();
context.fillStyle = color;
for (const mote of motes) {
const lit = litness(mote.x, mote.y);
// Unlit specks stay just visible, so the room has air in it.
context.globalAlpha = 0.07 + lit * 0.78;
context.beginPath();
context.arc(mote.x, mote.y, mote.radius * (0.8 + lit * 0.5), 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
};
// Reduced motion: one still frame. The shaft and the specks caught
// in it are the whole idea; the drifting only makes it breathe.
if (reduced) {
render();
const onResizeStill = () => {
resize();
motes = Array.from({ length: total }, spawn);
render();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let last = performance.now();
let elapsed = 0;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
for (const mote of motes) {
mote.y += mote.speed * delta;
mote.x += Math.sin(elapsed * mote.wanderRate + mote.phase) * config.wander * delta;
if (mote.y < -8) mote.y = height + 6;
if (mote.y > height + 8) mote.y = -6;
if (mote.x < -8) mote.x = width + 6;
if (mote.x > width + 8) mote.x = -6;
}
render();
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
motes = Array.from({ length: total }, spawn);
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color, angle, beamStrength]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
The quietest ambient field in the library: a still room with sun coming in at an angle, and dust turning over in it. The detail that carries it is that brightness is computed per speck from its perpendicular distance to the beam's axis — the same mote is dim, then bright, then dim again as it drifts through, so the light and the field belong to one room. A glow layered over an evenly lit field never does that. Good behind an empty state, an about screen, or anything that should feel unhurried rather than idle.
Empty stateAbout screenReading mode backgroundIdle screensaver