Snow
Canvas snowfall with per-flake sway, a depth ramp from soft foreground bokeh to crisp distant specks, and an optional drift that builds along the bottom edge and settles.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/snow.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Snow" component — a snowfall field
painted on one canvas, used as a hero / seasonal / auth backdrop. Its only
dependency is a cn() class merger (clsx + tailwind-merge). No particle library.
Contract
- export function Snow(props): props extend React.ComponentProps<"div">
(rest props spread onto the root, so ref / id / data-* pass through) plus:
- density?: number (default 90) — requested flake count. The effective count
is min(density, floor(area / 420), 700): an area cap so a hero-tuned value
dropped on a small card thins out instead of carpeting it, and a 700-flake
ceiling so a typo'd density cannot freeze the tab.
- layers?: number (default 3) — depth layers, clamped to 1..4 and rounded.
- speed?: number (default 1) — multiplier on the whole simulation (fall,
sway, wind, settling). 0 freezes it and the rAF loop never starts at all.
- wind?: number (default 8) — constant sideways push in CSS px/s, weighted
per layer. Negative blows left.
- accumulate?: boolean (default false) — let landed flakes build a drift
along the bottom edge. Off by default: a decoration should not grow a
white bar over someone's footer unless they asked for it.
- driftHeight?: number (default 28) — cap of that drift in CSS px,
additionally capped at 45% of the container height.
- tone?: "foreground" | "primary" | "muted" | "inverted" (default
"foreground") — which semantic token the snow is painted with. "inverted"
is var(--primary-foreground), the correct ink on a bg-primary panel.
- seed?: number (default 11) — integer seed. Same seed, same snowfall.
- children render above the canvas; className merges onto the root.
- "use client": canvas, rAF and observers. The root has no intrinsic size — it
is a container, so it gets its height from children or from className.
- Clamp every numeric prop up front and treat non-finite values as the
default: a NaN layer count empties the sky, density 1e6 freezes the tab, a
negative speed runs the snow back up into the ceiling.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper
for children, so content always sits above the field and the canvas can never
intercept a click. The component paints NO background of its own: the surface
belongs to the consumer.
- Depth ramp: layer i of n has d = i / (n - 1), or 0.5 when n is 1, and every
property is a lerp along d — radius 0.9 to 3.4 CSS px, alpha 0.34 to 0.80,
fall 16 to 52 px/s, sway amplitude 5 to 17 px, wind weight 0.45 to 1.3, blur
0 to 1.7 px. Note the DIRECTION of the blur: the near layer is the blurry
one. A camera focused on the content renders the flakes closest to the lens
as soft bokeh, and that out-of-focus foreground is what actually sells depth;
a uniformly crisp field reads as television static.
- Sprite atlas: bake one soft disc per layer into its own small offscreen
canvas — an arc fill under ctx.filter = "blur(Npx)", padded by 3 sigma so the
blur cannot clip at the sprite edge — then draw each flake as a single
drawImage of its layer's sprite, scaled by its own size fraction. Per-flake
sizes only ever scale the sprite DOWN, so no flake is an upscaled mush.
Blurring per flake per frame at full canvas size is the classic way this
effect burns a core; a blit is cheap and the blur is paid once, re-baked only
when the ink string or the DPR changes. A browser without canvas filters
simply gets crisp flakes.
- Deterministic field: every position, radius, alpha, fall speed, sway
amplitude, sway rate and phase comes from an integer hash of (flakeId, salt),
where flakeId mixes the seed with the flake's index. Address flakes by index
instead of pulling from a PRNG stream: growing the field after a resize then
never reshuffles the flakes already falling. Math.random() is never called —
not during render (purity/SSR) and not in the loop (screenshots must be
reproducible). Same seed, same snowfall.
- Motion: y integrates fall * ds and x integrates the layer's wind * ds,
wrapped with a modulo — not a single compare, so an absurd wind cannot fling
a flake outside the domain and strand it there. Sway is POSITIONAL, not
integrated: the drawn x is x + sin(elapsed * rate + phase) * amplitude, with
rate, phase and amplitude all per flake. Positional sway is what keeps a
frozen field showing every flake at its own offset instead of on one vertical
line, and it can never accumulate error. The seeding domain is the box
inflated by (max radius + max sway + 4) on both sides, so a swaying flake
never pops into view at an edge.
- Recycling: the flakes are a fixed pool, never allocated per frame. A flake
past the landing line is re-hashed to a new column and a new head start above
the top edge, salted by its own cycle counter — still deterministic, no
Math.random, and it does not fall down the same column forever. Re-entry is
spread over a band above the edge, otherwise everything that landed this
frame reappears on one visible horizontal line.
- Accumulation (accumulate): a Float64Array height field, one column per ~8 CSS
px, holds the drift. A flake lands when it is fully under the crest
(y - r >= height - h[col]) and deposits its own volume,
pi * r^2 * FLUFF / columnWidth, spread 0.25 / 0.5 / 0.25 over three columns so
a landing never spikes one pixel column. FLUFF = 12 because fresh snow packs
at well under a tenth of the density of ice; it is also the honest knob for
how fast the drift builds. Every column is clamped to
min(driftHeight, 45% of the height): at the cap the drift stops accepting
snow instead of burying the content.
- Settling: one avalanche sweep per frame. For each neighbouring pair, if the
step is steeper than the angle of repose (0.6 * columnWidth) half the excess
flows downhill, eased by min(0.5, ds * 7). That single sweep is what turns a
stack of point deposits into a crest that visibly slumps. The silhouette is a
quadratic curve through the midpoints of the column samples — a smooth ridge
rather than a staircase — and it is drawn AFTER the flakes, so a flake
sinking into it is occluded by it instead of popping out of existence.
- Ink: the canvas carries the tone token as an inline `color`, and the loop
reads getComputedStyle(canvas).color back and hands the string straight to
fillStyle, with alpha on globalAlpha. Never hand-parse a colour; passing the
computed string through means any syntax the browser understands works.
- Surface-aware degradation: white snow on a white page is invisible, and dark
snow on a white page is soot. Walk up from the canvas to the first ancestor
with an opaque background, resolve that colour through a 1x1 scratch canvas
(fillRect + getImageData — no hand parsing, so oklch() / color-mix() work)
and compute its relative luminance. Above 0.5 the whole effect steps back:
flake alpha x0.5, drift alpha 0.3 instead of 0.95 — a faint grey flurry
instead of a lie. This follows the SURFACE, not a theme class, so a dark
panel inside a light theme still gets full-strength snow and a light panel
inside a dark theme does not. Fall back to the `dark` class only if
getImageData is unavailable.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing. Try
observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
browsers that do not know that box throw a WebIDL TypeError from observe()
rather than ignoring it — and fall back to observe(canvas). devicePixelRatio
(capped at 2) is the AUTHORITY on scale; the device-pixel box is used only
when it agrees with it to within 0.01, purely to absorb sub-pixel rounding at
1.25x / 1.5x. Emulated and remoted surfaces exist where that box reports 1:1
while the page renders at 2x, and trusting it there ships a blurry canvas.
Re-apply ctx.setTransform after every resize (writing canvas.width resets the
context). Flakes are rescaled in place and the height field is resampled by
linear interpolation, so a resize slumps the drift instead of erasing it.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
on screen, document.visibilityState is "visible", motion is allowed and
speed > 0. dt is clamped to 1/30 s so a backgrounded tab cannot teleport the
snow on resume, and the time base resets when the loop restarts. A background
that burns a core in a hidden tab is a defect, not a trade-off.
- Theme flips: a MutationObserver on the html element (class / style /
data-theme) re-reads the ink and the surface, repaints the still frame when
the loop is paused, and schedules ONE more read ~400 ms later — surfaces
animated with transition-colors report an intermediate colour for a few
hundred ms, which is long enough to latch the wrong light/dark decision.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
snapshot false, so it is hydration-safe) and keep it in the effect deps.
Under reduce the loop never starts and exactly one frame is painted: a full
field, every flake already at its own sway offset, over a drift pre-filled
with a deterministic settled profile (two sine terms hashed from the seed).
A drift that can never build must not read as a missing feature, and a
reduced-motion background must never be a blank box. speed={0} takes the
same path.
- Cleanup on unmount: cancelAnimationFrame, the ResizeObserver, the
IntersectionObserver, the MutationObserver, the settle timeout and the
visibilitychange listener.
Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--foreground) /
var(--primary) / var(--muted-foreground) / var(--primary-foreground)
resolved through the canvas's own computed style, so light/dark and any
rebranded palette come for free. Alpha lives in globalAlpha, never in the
colour string. Flakes and drift always share one ink — that is what makes
them read as one material.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes.
- Accessibility: the canvas is aria-hidden and pointer-events-none and it has
no keyboard surface at all. It is decoration: it must never take focus,
intercept a click, or trap scroll. Children sit above it in the z-10 wrapper
and stay fully interactive and selectable.
- Cost, honestly: one drawImage per flake per frame, plus one path fill and one
height-field sweep of ~width/8 entries for the drift. Nothing is allocated
per frame — the pool, the sprites and the height field are all reused. The
default 90 flakes is negligible; a 1440x600 hero would have to ask for 700
before the ceiling bites, and 700 blits per frame is still comfortable. The
ceiling and the 420 px² per flake area cap are the two safety valves, and the
loop is suspended whenever the field is off screen or the tab is hidden.
Customization levers
- Depth feel: the six ramp pairs are the whole look. Widen the radius and blur
gap for more dramatic depth; set the near blur to 0 for a crisp graphic
snowfall, which is also cheaper (the sprites get tiny).
- Weather: the fall pair, `wind`, and the sway amplitude pair. Large sway plus
slow fall reads as feathers; small sway plus fast fall plus strong wind reads
as sleet. The sway rate range (0.28..0.9 rad/s) sets how nervous each flake
looks.
- Drift: FLUFF is the build rate, REPOSE the steepness the crest holds,
SETTLE_RATE how fast it slumps, COLUMN_PX the resolution (finer = lumpier and
slower to settle), MAX_DRIFT_FRACTION the hard ceiling on how much of the
container a drift may ever eat. The crest rises at roughly
(landings per second x per-flake volume) / width, so a wide hero at the
default density takes a minute to fill: raise `density` or `speed` for a
faster winter, or raise FLUFF if you want the same snowfall to pile quicker.
- Cost: `density` is the linear cost knob; MIN_AREA_PER_FLAKE (420) and
MAX_FLAKES (700) are the valves; drop MAX_DPR to 1 to halve fill cost if you
ship very large heroes.
- Palette: add a tone entry pointing at any token — var(--chart-1) for a
branded flurry, var(--primary-foreground) for an inverted panel.
- Degradation: SURFACE_LIGHT_MIN_LUMA, LIGHT_FLAKE_ALPHA and LIGHT_BANK_ALPHA
define the light-surface look; raise the flake alpha if your light theme is
greyer than white, or force one branch by replacing the probe with a constant
when you already know your surface.
- Shape: the sprite bake is one arc. Replace it with a six-point star path, a
glyph, or an image and every flake changes at once — at no per-frame cost,
because the shape is rasterised once per layer.Concepts
- Per-flake sway phase — the sideways offset is
sin(elapsed · rate + phase) · amplitudewith all three numbers hashed per flake, added at draw time rather than integrated into the position. Nothing falls in lockstep, the sway can never accumulate drift, and a frozen field still shows every flake at its own offset instead of on one vertical line. - Foreground bokeh, not background blur — the near layer is the blurry one. A camera focused on your copy renders the flakes closest to the lens as soft discs, so blur is a depth cue that runs the same direction as size, brightness and speed. Blur every layer equally and the field turns to static.
- Sprite atlas blit — one blurred disc is rasterised per layer at mount and re-baked only when the ink or the DPR changes; each flake is then a single
drawImagescaled down from it. The pool is fixed-size and nothing is allocated per frame — allocating per flake per frame is the failure mode at full-screen size. - Height field with an angle of repose — the drift is an array of column heights, not a shape. A landing adds
pi · r² · FLUFF / columnWidthacross three columns, and one avalanche sweep per frame moves half the excess wherever two neighbours are steeper than the repose angle. That is why a fresh pile visibly slumps into a ridge instead of standing as a spike. - The drift cap is a refusal — every column is clamped to
min(driftHeight, 45% of the height). Past the cap the snow is simply discarded: a decoration is never allowed to bury the content it sits behind, however long the tab is left open. - Reduced-motion still frame — under
prefers-reduced-motion: reducethe loop never starts, but the one painted frame is a full field over a pre-settled drift, so it reads as a snapshot of snowfall rather than a blank box.speed={0}paints the identical frame.