Snow Settle
Snow that falls and accumulates, building an uneven drift along the bottom edge.
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 · Snow Settle
*
* Snow that falls and then stays: the bottom edge is a heightmap of
* columns, and every flake that lands writes into the column it hit
* plus a little into its neighbours. Two things follow from that and
* neither needs a noise function. The drift piles unevenly because the
* same shared wind that sways the flakes decides which columns they
* reach; and because each column also creeps toward the average of its
* neighbours, a spike slumps into a bank instead of standing up like a
* bar chart.
*
* Once the drift holds its volume it compacts rather than growing, so
* fresh snow keeps roughening a bank that has stopped rising. Capping
* each column instead would fill every one to the ceiling and the
* slump would then iron the whole thing flat.
*
* 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 SnowSettleProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Flakes in the air at once. Overrides the variant's density. */
count?: number;
/**
* Flake and drift color. A cool pale blue rather than white, so the
* snow still reads against a light surface.
*/
color?: string;
/** Fires once when the drift first reaches its volume, and stops growing. */
onDriftFull?: () => void;
};
type VariantConfig = {
/** Flakes in the air at this setting. */
count: number;
/** Fall speed in px per second, before per-flake variation. */
fall: number;
/** Strength of the shared sideways wind, in px per second. */
wind: number;
/** Largest flake radius in px. */
size: number;
/**
* Mean depth the drift settles at, as a fraction of the height.
* Individual banks run above it — this is a volume, not a ceiling.
*/
cap: number;
/** How fast a column creeps toward its neighbours, per second. */
slump: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Still air, a thin rim of snow on the sill.
subtle: { count: 60, fall: 20, wind: 4, size: 1.9, cap: 0.12, slump: 0.6 },
// Steady snowfall building a shallow bank. All-purpose.
default: { count: 90, fall: 32, wind: 10, size: 2.3, cap: 0.18, slump: 0.8 },
// Weather: quicker fall, a wind that leans the drift to one side.
playful: { count: 130, fall: 48, wind: 20, size: 2.7, cap: 0.24, slump: 1 },
};
type Flake = {
x: number;
y: number;
radius: number;
speed: number;
/** Phase into the shared wind, so the field sways as a body. */
phase: number;
alpha: number;
};
/** Roughly the width of one heightmap column, in px. */
const COLUMN_WIDTH = 7;
export default function SnowSettle({
variant = "default",
count,
color = "#BAD0E6",
onDriftFull,
}: SnowSettleProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// The callback is held in a ref, and the ref is updated in its own
// effect rather than during render: an inline arrow from the parent
// then changes identity every render without restarting the snowfall.
const fullRef = useRef(onDriftFull);
useEffect(() => {
fullRef.current = onDriftFull;
}, [onDriftFull]);
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;
let columns: number[] = [];
let columnWidth = COLUMN_WIDTH;
let cap = 0;
let announced = false;
const random = (min: number, max: number) => min + Math.random() * (max - min);
/**
* A shallow uneven bank to start from, so the scene never opens on a
* bare edge. Two sines at unrelated frequencies read as a natural
* profile; one sine reads as a wave.
*/
const seedPile = (columnCount: number) => {
const offset = random(0, Math.PI * 2);
return Array.from({ length: columnCount }, (_, index) => {
const t = index / columnCount;
const shape =
Math.sin(t * 5.3 + offset) * 0.5 + Math.sin(t * 11.7 + offset * 1.7) * 0.25;
return cap * (0.16 + Math.max(0, shape) * 0.2);
});
};
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 columnCount = Math.max(8, Math.round(width / COLUMN_WIDTH));
columnWidth = width / columnCount;
cap = height * config.cap;
if (columns.length === 0) {
columns = seedPile(columnCount);
} else {
// Resample the existing profile rather than dropping it: the
// drift someone has been watching should survive a resize.
const previous = columns;
columns = Array.from({ length: columnCount }, (_, index) => {
const source = (index / (columnCount - 1 || 1)) * (previous.length - 1);
const low = Math.floor(source);
const high = Math.min(previous.length - 1, low + 1);
const t = source - low;
return Math.min(cap, previous[low] + (previous[high] - previous[low]) * t);
});
}
};
resize();
const spawn = (initial: boolean): Flake => ({
x: random(-10, width + 10),
y: initial ? random(-20, height * 0.85) : random(-30, -6),
radius: random(0.9, config.size),
speed: config.fall * random(0.7, 1.35),
phase: random(0, Math.PI * 2),
alpha: random(0.55, 1),
});
let flakes = Array.from({ length: total }, () => spawn(true));
/** Depth of the drift under an x position, interpolated between columns. */
const depthAt = (x: number) => {
const source = x / columnWidth - 0.5;
const low = Math.max(0, Math.min(columns.length - 1, Math.floor(source)));
const high = Math.max(0, Math.min(columns.length - 1, low + 1));
const t = Math.max(0, Math.min(1, source - low));
return columns[low] + (columns[high] - columns[low]) * t;
};
/** Write a landed flake into the heightmap, spread over three columns. */
const deposit = (x: number, radius: number) => {
const index = Math.max(
0,
Math.min(columns.length - 1, Math.round(x / columnWidth - 0.5))
);
const gain = radius * 3;
const spread: [number, number][] = [
[index - 1, 0.25],
[index, 0.5],
[index + 1, 0.25],
];
for (const [target, share] of spread) {
if (target < 0 || target >= columns.length) continue;
// A generous per-column ceiling only, so one lucky column cannot
// grow a tower between compactions. The real limit is volume.
columns[target] = Math.min(cap * 1.8, columns[target] + gain * share);
}
};
/**
* Compaction, and the reason the drift keeps its shape. Capping each
* column instead would let every column reach the ceiling and the
* bank would flatten into a bar — worse, the slump keeps smoothing,
* so it would end perfectly level. Holding the total volume and
* pressing the whole profile down proportionally means new snow
* keeps roughening a drift that is no longer growing.
*/
const compact = () => {
let volume = 0;
for (const value of columns) volume += value;
const budget = cap * columns.length;
if (volume <= budget) return;
const squeeze = budget / volume;
for (let index = 0; index < columns.length; index++) columns[index] *= squeeze;
if (!announced) {
announced = true;
fullRef.current?.();
}
};
const drawDrift = () => {
context.beginPath();
context.moveTo(-2, height + 2);
context.lineTo(-2, height - columns[0]);
// Midpoint smoothing: the curve passes between column tops rather
// than through them, which is what turns a staircase into a bank.
for (let index = 0; index < columns.length - 1; index++) {
const x = (index + 0.5) * columnWidth;
const y = height - columns[index];
const nextX = (index + 1.5) * columnWidth;
const nextY = height - columns[index + 1];
context.quadraticCurveTo(x, y, (x + nextX) / 2, (y + nextY) / 2);
}
context.lineTo(width + 2, height - columns[columns.length - 1]);
context.lineTo(width + 2, height + 2);
context.closePath();
context.fillStyle = color;
context.globalAlpha = 0.92;
context.fill();
context.globalAlpha = 1;
};
const render = () => {
context.clearRect(0, 0, width, height);
drawDrift();
context.fillStyle = color;
for (const flake of flakes) {
context.globalAlpha = flake.alpha;
context.beginPath();
context.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
};
// Reduced motion: the drift as it would look after a while, with
// flakes held in the air. The state of the scene is the content;
// the falling is only how it got there.
if (reduced) {
columns = columns.map((value) => Math.min(cap, value * 2.6));
render();
const onResizeStill = () => {
resize();
flakes = Array.from({ length: total }, () => spawn(true));
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;
// One shared current, sampled per flake at its own phase.
const gust = Math.sin(elapsed * 0.28) + Math.sin(elapsed * 0.11) * 0.5;
for (const flake of flakes) {
flake.y += flake.speed * delta;
flake.x +=
(gust + Math.sin(elapsed * 0.8 + flake.phase) * 0.35) * config.wind * delta;
if (flake.y >= height - depthAt(flake.x) - flake.radius * 0.5) {
deposit(flake.x, flake.radius);
Object.assign(flake, spawn(false));
continue;
}
if (flake.x < -12) flake.x = width + 8;
if (flake.x > width + 12) flake.x = -8;
}
// The slump. Without it the heightmap grows spikes exactly where
// the wind happens to hold steady, and snow does not do that.
const relaxed = columns.slice();
for (let index = 0; index < columns.length; index++) {
const left = columns[Math.max(0, index - 1)];
const right = columns[Math.min(columns.length - 1, index + 1)];
relaxed[index] +=
((left + right) / 2 - columns[index]) * Math.min(1, config.slump * delta);
}
columns = relaxed;
compact();
render();
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => resize();
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" }}
/>
);
}About this effect
Most snow effects are rain with white dots; this one has somewhere to land. The bottom edge is a heightmap of narrow columns, and every flake that reaches it writes into the column it hit plus a share into each neighbour. The drift comes out uneven for free — the same shared wind that sways the flakes decides which columns they reach — and because each column also creeps toward the average of its neighbours, a spike slumps into a bank rather than standing up like a bar chart. Once it holds its volume the drift compacts instead of growing, so fresh snow keeps roughening a bank that has stopped rising — capping each column instead would fill them all to the ceiling and the slump would iron the drift flat. It survives a resize by resampling the profile, and settles at a mean depth well under a fifth of the container.