snowflurry
v0.1.0
Published
Realistic falling snow for any website. WebGL2, zero dependencies, one script tag.
Maintainers
Readme
snowflurry
Realistic falling snow for any website. WebGL2, zero dependencies, one script tag.
9.5 KB gzipped, zero dependencies, every option below live.
npm install snowflurryOr no build step, and no JavaScript at all:
<script src="https://cdn.jsdelivr.net/npm/snowflurry/dist/snowflurry.standalone.js"
data-snow-count="300"
data-snow-wind-strength=".5"></script>That's the whole integration. See no JavaScript at all.
Snow
import { Snow } from 'snowflurry'
/* Every option shown here is already the default — `new Snow()` gives you this. */
const snow = new Snow({
target: document.body,
count: 600,
size: [3, 22],
layers: 5,
physics: { fallSpeed: [28, 85], turbulence: 0.9, rotation: true },
wind: { strength: 0.4, gustiness: 0.65, direction: 18 },
})
snow.start()
snow.pause()
snow.updateConfig({ wind: { strength: 0.8 } })
snow.destroy()updateConfig deep-merges plain objects, so the patch above keeps gustiness.
Ranges and arrays replace wholesale. It never restarts the loop — a slider can
drive it every frame.
Options
| option | default | |
|---|---|---|
| target | document.body | the area to cover and the stacking reference — not the parent node |
| scope | 'auto' | 'viewport', 'element', or 'auto' (viewport for body/html, element otherwise) |
| count | 600 | flakes across all layers |
| size | [3, 22] | px range at depth 1, drawn with a bias toward the small end |
| color | '#ffffff' | |
| opacity | 1 | |
| layers | 5 | parallax depth bands; 1 disables parallax |
| zIndex | 9999 | over page content, under a modal |
| seed | null | a number or string makes the whole simulation deterministic |
| maxDPR | 2 | ceiling on devicePixelRatio — the cheapest performance lever |
| quality | 'auto' | 'auto' sheds count and resolution when frames run long |
| respectReducedMotion | true | honour prefers-reduced-motion: reduce |
| onUnsupported | null | called by start() instead of starting, when WebGL2 is absent |
| physics.fallSpeed | [28, 85] | px/s at depth 1 |
| physics.turbulence | 0.9 | amplitude of the noise-driven sway |
| physics.rotation | true | let flakes tumble |
| wind.strength | 0.4 | negative blows left |
| wind.gustiness | 0.65 | 0 is a steady breeze |
| wind.direction | 18 | degrees off vertical |
| shadow.onFlakes | true | near flakes darken far ones; in-canvas, cheap |
| shadow.onContent | false | snow shadows the page's own content — see below |
| shadow.lightAngle | 45 | degrees; 0 is directly overhead |
| shadow.intensity | 0.3 | |
Read-only: running, supported, reducedMotion, frameMs, qualityScale,
config, flakes, shadow.
On bundle size
A branch selected at run time by a string cannot be tree-shaken, so every
string-configured option here ships all of its code whichever value you set —
scope, quality, and any future shape option. No exports-map care changes
that. Measured: quality: 'fixed' and quality: 'auto' bundles are 2 bytes
apart.
That is why a pattern option with a procedural six-fold crystal was removed
rather than left in unused — it was 1.4 KB gzipped that every consumer paid for
whether or not they selected it. If flake shapes return, they need to be
import-gated, which is how autoInit works: reachable only by importing it, so a
consumer who never does pays nothing (682 bytes).
No JavaScript at all
The standalone build configures itself from data-snow-* attributes and starts
on DOMContentLoaded. Two forms:
<!-- on the script tag: snow over the whole page -->
<script src="snowflurry.standalone.js" data-snow-count="300"></script>
<!-- on any element: snow scoped to that element's box -->
<div data-snow data-snow-count="60" data-snow-size="2,5">a hero</div>A bare data-snow means "snow here, with defaults". Attribute names are the
option paths, flattened to kebab-case — so every option in the table above is
settable from markup, because the attribute map is derived from the defaults
rather than written out by hand:
| option | attribute |
|---|---|
| count | data-snow-count="300" |
| size | data-snow-size="2,6" |
| maxDPR | data-snow-max-dpr="1" |
| wind.strength | data-snow-wind-strength=".5" |
| physics.fallSpeed | data-snow-physics-fall-speed="10,90" |
| shadow.onContent | data-snow-shadow-on-content |
Types come from the option's own type. Ranges take min,max or a single number.
Booleans treat presence as true, the way disabled does, and accept
false/0/no/off. Unknown attributes warn rather than being ignored —
a silent typo in markup has no other way of surfacing.
Auto-init is opt-in: with no data-snow* attributes anywhere, loading the file
gives you the global API and nothing else. target and onUnsupported aren't
settable from markup, since neither survives being a string.
Instances created this way are collected on the global, so a declarative page still has a handle on its own snow:
<script>snowflurry.instances[0].updateConfig({ wind: { strength: 1 } })</script>Scoped snow inherits the element's box, so putting data-snow on something 19px
tall gives you 200 flakes in a 19px strip. Set data-snow-count to suit the box.
The same function is available to apps importing from npm, for markup-driven config without the standalone build:
import { autoInit } from 'snowflurry'
autoInit() // after DOM ready — it does not waitWhere the canvases go
Two position: fixed, pointer-events: none, aria-hidden canvases, mounted on
<body>. The host page's DOM and CSS are never touched.
They mount on <body> and not on target on purpose. A transform, filter,
perspective or will-change on any ancestor makes that element the containing
block for position: fixed descendants — which would clip the snow to a box in
the middle of the page, with no error anywhere. That's not exotic: it's smooth
scroll libraries, page-transition libraries, and every translate3d(0,0,0)
compositing hack. So target means two things, and neither is "parent node":
the area to cover, and the stacking reference.
Element scope keeps the canvases fixed and moves their inset from the target's rect once per frame, so scrolling and reflow need no observers.
shadow.onContent
Snow casting shadow onto the page's own text and images. A second canvas below the flakes and above the page, drawing one soft dark ellipse per flake.
It uses no mix-blend-mode, and that is the interesting part. A neutral black
shadow composites identically with multiply and with ordinary source-over:
multiply: a*(B*S) + (1-a)*B, with S = 0 → (1-a)*B
source-over: a*S + (1-a)*B, with S = 0 → (1-a)*BVerified in a browser against a saturated gradient rather than trusted as algebra — max channel difference 1, from rounding. The same test with a blue-tinted shadow differs by up to 103, which is where multiply genuinely matters: it scales the backdrop instead of washing it toward the shadow's own colour. A neutral shadow gets nothing from it.
Dropping the blend mode removes both things that would have made this the fragile part of the library:
- No blend group to lose.
mix-blend-modeblends against the nearest ancestor that isolates, so anisolation: isolate, anopacity < 1, a filter or a transform anywhere above<body>would have left it with a transparent backdrop and hard dark blobs. There is no backdrop involved now, so nothing in the host page's CSS can defeat it. - No compositor cliff. A blend-mode layer forces the backdrop to be read back and re-blended every frame the canvas changes, full viewport — a known problem in Safari, and a scroll-performance risk. A normally composited layer never reads the backdrop.
The pass is 2D at 35% resolution, not a second WebGL2 context: it draws blurred grey blobs from one baked bitmap, and nobody can resolve detail in something deliberately out of focus.
It only works on light content. A shadow removes luminance, so it does nothing against a dark background. That's the effect being correct, not broken.
Cost, measured, CPU-bound and linear in flake count:
| flakes | shadow pass | of a 60fps frame | |---|---|---| | 200 | 0.1 ms | 0.6% | | 600 | 0.3 ms | 1.8% | | 1500 | 0.6 ms | 3.6% | | 4000 | 1.8 ms | 10.8% |
It's still off by default — it's a deliberate art direction choice, not something every page wants — but the reasons are now cost and taste rather than fragility.
intensity means occlusion of the area a flake itself covers, spread over its
penumbra. The spread doesn't multiply the darkness: widening it makes the shadow
more diffuse and not darker, which is what a softness control should do. Without
that normalisation a few hundred large flakes lay a grey wash over the page and
body text stops being readable.
shadow.onFlakes is a different mechanism entirely — a framebuffer multiply
inside the flakes canvas, no second canvas, on by default. See
Depth.
How it moves
Four things act on a flake, and they are deliberately separable so a control panel can isolate any one of them:
physics.fallSpeedis a magnitude, not a vertical velocity. Each flake draws its own from the range and keeps it for its lifetime.wind.directiontilts the whole fall vector, so a flake at 30° falls atcos(30°)down andsin(30°)across — the speed splits rather than growing. Clamped to ±85°: past a right angle nothing reaches the bottom to recycle, and at 180° snow falls upward off the screen and never returns.wind.strengthandwind.gustinessare one value applied to every flake at once. That is what makes a gust read as weather rather than as each flake having its own — the whole sky surges and eases together.physics.turbulenceis the small independent flutter on top, one noise slice per flake, evolving in time.
Flakes have mass. Horizontal velocity chases its target rather than being assigned, so a flake accelerates into a gust over a few frames, and a large flake ploughs through one that a small flake rides. Without that, turbulence reads as vibration.
physics.rotation tumbles flakes, and it is visible even on a round sprite:
a real flake is a flat plate, so one turning edge-on presents less area. The
shader foreshortens by |cos| before rotating, which is where snow's
characteristic shimmer comes from.
One deliberate non-feature: sway does not depend on a flake's position. Sampling the noise field spatially makes neighbouring flakes sway together, which sounds better and measurably is not — a velocity field that depends on position has divergence, so flakes accumulate onto its convergence lines and the snow ropes into strands with bare gaps over about forty seconds. The tests hold the distribution flat out to two minutes.
Depth
layers splits the snow into that many depth bands. Depth is assigned per flake
at spawn and quantised, so there are exactly layers distinct depths — not a
continuous gradient. Continuous depth sounds better and is worse: it dissolves
the near/mid/far reading into a uniform smear and makes ordered inter-flake
shadows impossible.
Three cues scale with depth, all of them downward from the configured value:
| | nearest layer | furthest layer |
|---|---|---|
| size | size as configured | 50% |
| fall speed | fallSpeed as configured | 45% |
| opacity | full | 45% |
So size: [2, 6] is a promise about the flakes closest to the viewer, and
layers: 1 collapses everything to exactly the configured values. Fall speed is
the cue that does most of the work — distant things appearing to move slowly is a
stronger signal than them being small or faint.
Dragging layers live re-bands the flakes that exist rather than re-rolling
them, so relative depth order survives and the field doesn't pop.
shadow.onFlakes
Near flakes darken far flakes. This needs no CSS blend mode and no second
canvas — the layers are drawn furthest to nearest, and each layer's shadow pass
goes down after every further layer's flakes and before its own, multiplying the
framebuffer with (ZERO, ONE_MINUS_SRC_ALPHA). A flake therefore never shadows
itself or its own layer.
It is an overlap effect, so its visibility scales with density. Measured as
the drop in total rendered luminance at intensity: 0.85:
| | luminance drop | |---|---| | 400 flakes at 10–22px | 0.3% | | 1500 at 14–30px | 2.4% | | 4000 at 18–40px | 7.9% | | 4000 at 30–70px | 13.7% |
At the default count: 200, size: [2, 6] it is well under a tenth of a percent —
invisible. It costs roughly 1.9× extra fragment work whether or not it shows,
because the shadow sprite is 1.7× the flake's size and is drawn for every layer
but the furthest. If you are running sparse, small snow, turn it off.
It is already skipped entirely when layers is 1, when intensity is 0, or when
onFlakes is false — those cost nothing.
Adaptive quality
quality: 'auto' (the default) draws fewer flakes when the library's own
per-frame cost runs long, down to a third of count, and gives them back when it
recovers. snow.qualityScale reports what fraction is currently drawn.
Two things about it are deliberate:
- It sheds by drawing fewer flakes, never by resizing the pool. Resizing would reallocate and refill, so the field would visibly reshuffle every time the frame budget wobbled — a fix that looks worse than the problem. Shed flakes are still simulated, just not drawn, so they are in the right place when they come back.
- The signal is our own work time, not the frame interval. A 30Hz display, a
throttled background tab, or a browser pacing
requestAnimationFramedifferently all produce a long interval that shedding cannot fix. Reacting to it would starve the effect on hardware that was never struggling.
There is hysteresis between shedding and restoring, and recovery is slower than
shedding, so the count doesn't oscillate and the snow doesn't pulse. Set
quality: 'fixed' to opt out; maxDPR is the manual lever and a stronger one.
Determinism
seed makes every draw reproducible, which is the only thing that makes a
particle system screenshot-testable:
new Snow({ seed: 'winter' }) // identical frames every runReduced motion and unsupported browsers
prefers-reduced-motion: reduce is honoured by default: the field lays out and
paints, but never advances. Set respectReducedMotion: false to override.
Without WebGL2, start() does nothing and calls onUnsupported. Snow is
decoration — a page should not fall over because it couldn't have any.
License
MIT
