npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

snowflurry

v0.1.0

Published

Realistic falling snow for any website. WebGL2, zero dependencies, one script tag.

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 snowflurry

Or 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 wait

Where 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)*B

Verified 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-mode blends against the nearest ancestor that isolates, so an isolation: isolate, an opacity < 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.fallSpeed is a magnitude, not a vertical velocity. Each flake draws its own from the range and keeps it for its lifetime.
  • wind.direction tilts the whole fall vector, so a flake at 30° falls at cos(30°) down and sin(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.strength and wind.gustiness are 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.turbulence is 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 requestAnimationFrame differently 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 run

Reduced 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