Flurry
Quantized pixel snowfall: chunky flakes locked to a crisp unsmoothed pixel grid fall on three parallax depth layers with distinct speeds, sizes and ice-blue tints. Per-flake sine wander, seeded wind gusts that shear layers by depth, and flakes that settle briefly into 1-block drifts on an invisible floor. Canvas 2D, seeded, offscreen-paused, reduced-motion-safe.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface FlurryProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Layer tints, nearest → farthest. Index `0` colors the big foreground
* flakes, `1` the mid layer, `2` the distant dust. Defaults to an icy
* white-to-blue ramp on a cold near-black bed — pass warm grays for ash,
* greens for pollen, anything for confetti-weather.
* @default ["#eff6ff", "#bfdbfe", "#93c5fd"]
*/
colors?: string[];
/** Flake-count multiplier across all layers, 0.2–3. @default 1 */
density?: number;
/** Fall / gust speed multiplier. 1 = a lazy flurry. @default 1 */
speed?: number;
/**
* Gust strength multiplier, 0–3. Gusts arrive on a seeded schedule and
* shear every layer proportionally to its depth; `0` disables them.
* @default 1
*/
wind?: number;
/**
* Constant fall angle in degrees from vertical (positive leans right).
* Clamped −45…45. @default 6
*/
drift?: number;
/** Chunk size of one "pixel" in CSS px — flakes snap to this grid. Clamped 2–8. @default 3 */
pixelSize?: number;
/**
* Let flakes settle on an invisible floor line: each lands, flattens into a
* 1-block drift for a moment, then fades and recycles. @default true
*/
settle?: boolean;
/** Overall flake brightness, 0–2. @default 1 */
intensity?: number;
/** Deterministic seed — same seed always renders the same snowfall. @default 1 */
seed?: number;
/** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
dpr?: number;
/** Freeze the flurry at a static mid-fall scatter. @default false */
paused?: boolean;
}
const DEFAULT_COLORS: string[] = ["#eff6ff", "#bfdbfe", "#93c5fd"];
/** How strongly gusts shear each layer — near snow catches the wind, far snow barely stirs. */
const GUST_SHEAR: [number, number, number] = [1, 0.55, 0.28];
/** Deterministic mulberry32 — seeded so the field is identical across mounts (SSR-safe). */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const int = parseInt(h, 16);
if (Number.isNaN(int) || h.length !== 6) return [239, 246, 255];
return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}
/** Cold near-black bed derived from the far-layer tint — never flat #000. */
function baseFill(far: [number, number, number]): string {
return `rgb(${(4 + far[0] * 0.012) | 0}, ${(6 + far[1] * 0.015) | 0}, ${(10 + far[2] * 0.024) | 0})`;
}
interface Flake {
x: number; // CSS px (unquantized sim position)
y: number;
layer: 0 | 1 | 2; // 0 near, 1 mid, 2 far
vy: number; // fall speed, CSS px/s (before global speed)
wanderAmp: number; // horizontal sine sway, CSS px
wanderFreq: number;
wanderPhase: number;
baseAlpha: number;
cross: boolean; // near flakes only: plus-shaped sparkle variant
settled: boolean;
settleT: number; // seconds spent on the floor
settleDur: number;
}
interface Internals {
ctx: CanvasRenderingContext2D;
width: number; // CSS px
height: number;
P: number; // pixel-block size, CSS px
palette: [number, number, number][];
bg: string;
flakes: Flake[];
rng: () => number; // runtime recycling stream
// Live knobs (mutated in place — no canvas rebuild on change).
speed: number;
wind: number;
driftTan: number;
intensity: number;
settle: boolean;
// Seeded gust schedule.
gustStart: number;
gustDur: number;
gustStrength: number; // signed CSS px/s at envelope peak
}
function makeFlake(rng: () => number, layer: 0 | 1 | 2, w: number, h: number, initial: boolean): Flake {
const near = layer === 0;
const mid = layer === 1;
return {
x: rng() * w,
// First build scatters mid-fall across the full height so frame zero (and
// the reduced-motion still) is already a populated snowfall.
y: initial ? rng() * h : -4 - rng() * h * 0.15,
layer,
vy: (near ? 46 : mid ? 26 : 13) * (0.75 + rng() * 0.5),
wanderAmp: near ? 8 + rng() * 8 : mid ? 5 + rng() * 5 : 2 + rng() * 3,
wanderFreq: 0.3 + rng() * 0.6,
wanderPhase: rng() * Math.PI * 2,
baseAlpha: near ? 0.95 : mid ? 0.6 : 0.35,
cross: near && rng() < 0.3,
settled: false,
settleT: 0,
settleDur: 1.2 + rng() * 1.4,
};
}
function buildField(w: number, h: number, density: number, settle: boolean, seed: number): Flake[] {
const rng = mulberry32((seed ^ 0x5eed5307) >>> 0);
const area = w * h;
const farCount = Math.min(320, Math.max(16, Math.round((area / 3800) * density)));
const midCount = Math.round(farCount * 0.55);
const nearCount = Math.max(3, Math.round(farCount * 0.28));
const flakes: Flake[] = [];
const push = (layer: 0 | 1 | 2, n: number) => {
for (let i = 0; i < n; i++) {
const f = makeFlake(rng, layer, w, h, true);
// A seeded handful start already settled so the floor detail shows in
// the static frame and from frame one.
if (settle && rng() < 0.08) {
f.settled = true;
f.settleT = rng() * f.settleDur * 0.7;
}
flakes.push(f);
}
};
push(2, farCount);
push(1, midCount);
push(0, nearCount);
return flakes;
}
/** Send a flake back above the top edge with fresh seeded character. */
function respawn(f: Flake, it: Internals) {
const fresh = makeFlake(it.rng, f.layer, it.width, it.height, false);
Object.assign(f, fresh);
}
/**
* Smooth gust envelope from the seeded schedule: 0 between gusts, sin² hump
* during one. Advancing past a gust schedules the next from the seeded stream,
* so the whole storm is deterministic.
*/
function gustAt(it: Internals, t: number): number {
const end = it.gustStart + it.gustDur;
if (t >= end) {
it.gustStart = t + 3 + it.rng() * 6;
it.gustDur = 1.2 + it.rng() * 1.8;
it.gustStrength = (it.rng() < 0.5 ? -1 : 1) * (34 + it.rng() * 52);
return 0;
}
if (t < it.gustStart) return 0;
const p = (t - it.gustStart) / it.gustDur;
const s = Math.sin(Math.PI * p);
return it.gustStrength * s * s;
}
function fillBlock(it: Internals, qx: number, qy: number, w: number, h: number, rgb: [number, number, number], alpha: number) {
if (alpha <= 0.01) return;
it.ctx.fillStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${Math.min(alpha, 1)})`;
it.ctx.fillRect(qx, qy, w, h);
}
/** Draw one flake, quantized to the pixel grid — integer blocks, zero subpixel churn. */
function drawFlake(it: Internals, f: Flake, t: number) {
const { P } = it;
const rgb = it.palette[f.layer];
const floorY = Math.max(it.height - 2 * P, P);
if (f.settled) {
// Landed: a flat 1-block drift, one block wider than the flake, fading out
// over the back half of its rest.
const fade = 1 - Math.max(0, (f.settleT / f.settleDur - 0.4) / 0.6);
const wBlocks = f.layer === 0 ? 3 : 2;
const qx = Math.round(f.x / P) * P;
const qy = Math.round(floorY / P) * P;
fillBlock(it, qx - ((wBlocks / 2) | 0) * P, qy, wBlocks * P, P, rgb, f.baseAlpha * fade * it.intensity);
return;
}
const xd = f.x + Math.sin(f.wanderPhase + t * f.wanderFreq) * f.wanderAmp;
const qx = Math.round(xd / P) * P;
const qy = Math.round(f.y / P) * P;
const a = f.baseAlpha * it.intensity;
if (f.layer === 0) {
if (f.cross) {
// Plus-shaped sparkle flake — pixel-craft variety in the near field.
fillBlock(it, qx, qy, P, P, rgb, a);
fillBlock(it, qx - P, qy, P, P, rgb, a * 0.75);
fillBlock(it, qx + P, qy, P, P, rgb, a * 0.75);
fillBlock(it, qx, qy - P, P, P, rgb, a * 0.75);
fillBlock(it, qx, qy + P, P, P, rgb, a * 0.75);
} else {
fillBlock(it, qx, qy, 2 * P, 2 * P, rgb, a);
}
} else {
fillBlock(it, qx, qy, P, P, rgb, a);
}
}
/** One frame. `animate` false renders the designed static scatter (no advance). */
function draw(it: Internals, dt: number, t: number, animate: boolean) {
const { ctx, width, height, P } = it;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = it.bg;
ctx.fillRect(0, 0, width, height);
const gust = animate ? gustAt(it, t) * it.wind : 0;
const floorY = Math.max(height - 2 * P, P);
const margin = 4 * P;
const span = width + 2 * margin;
for (let i = 0; i < it.flakes.length; i++) {
const f = it.flakes[i];
if (animate) {
if (f.settled) {
f.settleT += dt;
if (f.settleT >= f.settleDur) respawn(f, it);
} else {
const fall = f.vy * it.speed;
f.y += fall * dt;
f.x += (fall * it.driftTan + gust * GUST_SHEAR[f.layer]) * dt;
// Wrap horizontally so drift and gusts never thin the field.
if (f.x < -margin) f.x += span;
else if (f.x > width + margin) f.x -= span;
if (f.y >= floorY) {
if (it.settle && it.rng() < 0.55) {
f.settled = true;
f.settleT = 0;
f.y = floorY;
} else {
respawn(f, it);
}
}
}
}
drawFlake(it, f, animate ? t : 0);
}
}
/**
* Flurry — quantized pixel snowfall. Chunky flakes locked to a crisp,
* unsmoothed pixel grid drift down on three parallax depth layers (big bright
* near chunks, mid blocks, distant dust — each with its own fall speed and
* ice-blue tint). Every flake wanders on its own horizontal sine; seeded wind
* gusts sweep through and shear all layers proportionally to depth; flakes
* settle briefly on an invisible floor line — flattening into a 1-block drift
* before fading. Canvas 2D: integer block positions (no subpixel churn),
* seeded PRNG (SSR-safe), paused offscreen and on hidden tabs, reduced-motion
* renders a static seeded mid-fall scatter. Drop it inside any `relative`
* container.
*/
export function Flurry({
colors = DEFAULT_COLORS,
density = 1,
speed = 1,
wind = 1,
drift = 6,
pixelSize = 3,
settle = true,
intensity = 1,
seed = 1,
dpr = 2,
paused = false,
className,
...props
}: FlurryProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const internalsRef = React.useRef<Internals | null>(null);
const reducedMotion = useReducedMotion();
const frozen = paused || reducedMotion;
const lastElapsedRef = React.useRef(0);
const P = Math.round(Math.min(Math.max(pixelSize, 2), 8));
const dens = Math.min(Math.max(density, 0.2), 3);
const speedF = Math.max(speed, 0.01);
const windF = Math.min(Math.max(wind, 0), 3);
const driftTan = Math.tan((Math.min(Math.max(drift, -45), 45) * Math.PI) / 180);
const intens = Math.min(Math.max(intensity, 0), 2);
const colorsKey = colors.join(",");
const tick = React.useCallback((elapsed: number) => {
const it = internalsRef.current;
if (!it) return;
let dt = elapsed - lastElapsedRef.current;
lastElapsedRef.current = elapsed;
if (dt > 0.05) dt = 0.05; // clamp tab-restore jumps
if (dt < 0) dt = 0;
// The rAF loop only runs while unfrozen (useVisibilityPause gates it), so
// ticks always animate; the frozen still is drawn by the effects below.
draw(it, dt, elapsed, true);
}, []);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: frozen });
// Canvas + field setup; rebuilds only on structural props (size via observer).
React.useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const effectiveDpr = Math.min(
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
Math.min(Math.max(dpr, 1), 2)
);
const source = colors.length ? colors : DEFAULT_COLORS;
const palette = source.map(hexToRgb) as [number, number, number][];
while (palette.length < 3) palette.push(palette[palette.length - 1]);
const rng = mulberry32((seed ^ 0x9e3779b9) >>> 0);
const it: Internals = {
ctx,
width: 0,
height: 0,
P,
palette,
bg: baseFill(palette[2]),
flakes: [],
rng,
speed: speedF,
wind: windF,
driftTan,
intensity: intens,
settle,
gustStart: 2 + rng() * 4,
gustDur: 1.2 + rng() * 1.8,
gustStrength: (rng() < 0.5 ? -1 : 1) * (34 + rng() * 52),
};
internalsRef.current = it;
const resize = () => {
const w = Math.max(container.clientWidth, 1);
const h = Math.max(container.clientHeight, 1);
canvas.width = Math.round(w * effectiveDpr);
canvas.height = Math.round(h * effectiveDpr);
canvas.style.width = "100%";
canvas.style.height = "100%";
ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
ctx.imageSmoothingEnabled = false;
it.width = w;
it.height = h;
it.flakes = buildField(w, h, dens, it.settle, seed);
// Never flash empty — paint the seeded scatter immediately.
draw(it, 0, 0, false);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
return () => {
ro.disconnect();
internalsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [P, dens, seed, dpr, colorsKey]);
// Keep behavior knobs live in place (no rebuild), and re-render the frozen
// frame so prop tweaks show while paused / reduced-motion.
React.useEffect(() => {
const it = internalsRef.current;
if (!it) return;
it.speed = speedF;
it.wind = windF;
it.driftTan = driftTan;
it.intensity = intens;
it.settle = settle;
if (frozen) draw(it, 0, 0, false);
}, [speedF, windF, driftTan, intens, settle, frozen]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="flurry"
className={cn("absolute inset-0 -z-10 overflow-hidden bg-neutral-950", className)}
{...props}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/flurryProps
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | string[] | ["#eff6ff", "#bfdbfe", "#93c5fd"] | Layer tints, nearest → farthest. Index 0 colors the big foreground flakes, 1 the mid layer, 2 the distant dust. Defaults to an icy white-to-blue ramp on a cold near-black bed — pass warm grays for ash, greens for pollen, anything for confetti-weather. |
| density | number | 1 | Flake-count multiplier across all layers, 0.2–3. |
| speed | number | 1 | Fall / gust speed multiplier. 1 = a lazy flurry. |
| wind | number | 1 | Gust strength multiplier, 0–3. Gusts arrive on a seeded schedule and shear every layer proportionally to its depth; 0 disables them. |
| drift | number | 6 | Constant fall angle in degrees from vertical (positive leans right). Clamped −45…45. |
| pixelSize | number | 3 | Chunk size of one "pixel" in CSS px — flakes snap to this grid. Clamped 2–8. |
| settle | boolean | true | Let flakes settle on an invisible floor line: each lands, flattens into a 1-block drift for a moment, then fades and recycles. |
| intensity | number | 1 | Overall flake brightness, 0–2. |
| seed | number | 1 | Deterministic seed — same seed always renders the same snowfall. |
| dpr | number | 2 | devicePixelRatio ceiling. Clamped 1–2. |
| paused | boolean | false | Freeze the flurry at a static mid-fall scatter. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.