Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Which semantic token the flakes and the drift are painted with. */
export type SnowTone = "foreground" | "primary" | "muted" | "inverted"

/**
 * Flake ink per tone — semantic tokens only. The value is written onto the
 * canvas as an inline `color`, then the *computed* string is read back and
 * handed to the 2D context verbatim. Any syntax the browser resolves
 * (oklch(), color-mix(), a brand colour the consumer put behind the token)
 * works, and nothing here ever parses or hard-codes a colour.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/snow.json

Prompt

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) · amplitude with 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 drawImage scaled 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 / columnWidth across 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: reduce the 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.

On This Page