← Particles

Snowfall

Gentle flakes drift down a section, swaying as they fall. Close ones are bigger, softer and faster, far ones small and slow, and now and then a six-armed crystal turns past.

Season’s greetings

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-snowfall to a section, hero or card. It looks best on a dark background.

Options

Add any of these attributes to the same element.

data-densityFlakes per 100 × 100 px. Default 1.6.
data-speedSpeed multiplier. Default 1.
data-windSideways drift, from -1 (left) to 1 (right). Default 0.2.
data-colorFlake colour. Default white.

It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the snow hangs still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Snowfall: gentle flakes drift down a section, swaying as they fall. Near
    // flakes are bigger, faster and a little soft; far ones are small and slow,
    // and the biggest are six-armed crystals that turn as they fall. Drawn on a
    // transparent canvas behind your content, and paused when off screen.
    // Usage: <section data-snowfall style="background:#0f1e3d">…</section>
    // Optional: data-density (flakes per 100 × 100 px, default 1.6),
    // data-speed (default 1), data-wind (sideways drift, -1 to 1, default
    // 0.2), data-color (default white).
    var DEFAULTS = { density: 1.6, speed: 1, wind: 0.2, color: '#ffffff' };

    var css =
      '[data-snowfall]{position:relative;isolation:isolate;overflow:hidden}' +
      '.snf-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // Pre-drawn sprites: a soft round flake and a crystal.
    function sprites(color) {
      var S = 64;
      var dot = document.createElement('canvas');
      dot.width = dot.height = S;
      var c = dot.getContext('2d');
      c.fillStyle = color;
      c.fillRect(0, 0, S, S);
      // Then cut it to a disc with a soft edge, whatever the colour.
      c.globalCompositeOperation = 'destination-in';
      var m = c.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
      m.addColorStop(0, '#000');
      m.addColorStop(0.4, 'rgba(0,0,0,.9)');
      m.addColorStop(1, 'rgba(0,0,0,0)');
      c.fillStyle = m;
      c.fillRect(0, 0, S, S);

      var star = document.createElement('canvas');
      star.width = star.height = S;
      c = star.getContext('2d');
      c.translate(S / 2, S / 2);
      c.strokeStyle = color;
      c.lineCap = 'round';
      c.lineWidth = 3.2;
      for (var i = 0; i < 6; i++) {
        c.beginPath();
        c.moveTo(0, 0);
        c.lineTo(0, -28);
        c.moveTo(0, -14);
        c.lineTo(-8, -21);
        c.moveTo(0, -14);
        c.lineTo(8, -21);
        c.stroke();
        c.rotate(Math.PI / 3);
      }
      c.fillStyle = color;
      c.beginPath();
      c.arc(0, 0, 4, 0, Math.PI * 2);
      c.fill();
      return { dot: dot, star: star };
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'snf-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var density = +d.density || DEFAULTS.density;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var wind = d.wind !== undefined ? +d.wind : DEFAULTS.wind;
      var img = sprites(d.color || DEFAULTS.color);
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, t = 0;
      var flakes = [];

      // depth runs from 0 (far away) to 1 (close up).
      function flake(f, top) {
        var z = Math.pow(Math.random(), 1.8);
        f.z = z;
        f.r = 1.6 + z * 6.5;
        f.crystal = z > 0.8 && Math.random() < 0.6;
        if (f.crystal) f.r *= 1.7;
        f.x = Math.random() * w;
        f.y = top ? -f.r * 2 - Math.random() * 40 : Math.random() * h;
        f.vy = (0.012 + z * 0.05) * R(0.8, 1.2, 2);
        f.sway = R(10, 30) * (0.4 + z);
        f.freq = R(0.4, 1.1, 2) / 1000;
        f.phase = Math.random() * Math.PI * 2;
        f.spin = R(-0.6, 0.6, 2) / 1000;
        f.angle = Math.random() * Math.PI;
        f.alpha = 0.5 + z * 0.5;
        return f;
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        var n = Math.round(Math.min(700, Math.max(30, (w * h * density) / 10000)));
        while (flakes.length < n) flakes.push(flake({}, false));
        flakes.length = n;
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        for (var i = 0; i < flakes.length; i++) {
          var f = flakes[i];
          var x = f.x + Math.sin(t * f.freq + f.phase) * f.sway;
          ctx.globalAlpha = f.alpha;
          if (f.crystal) {
            var c = Math.cos(f.angle), s = Math.sin(f.angle);
            ctx.setTransform(c * dpr, s * dpr, -s * dpr, c * dpr, x * dpr, f.y * dpr);
            ctx.drawImage(img.star, -f.r, -f.r, f.r * 2, f.r * 2);
            ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
          } else {
            ctx.drawImage(img.dot, x - f.r, f.y - f.r, f.r * 2, f.r * 2);
          }
        }
        ctx.globalAlpha = 1;
      }

      function advance(dt) {
        t += dt;
        var unit = Math.max(0.6, h / 500);
        for (var i = 0; i < flakes.length; i++) {
          var f = flakes[i];
          f.y += f.vy * dt * speed * unit;
          f.x += wind * (0.01 + f.z * 0.03) * dt * speed;
          f.angle += f.spin * dt;
          if (f.y - f.r > h + f.sway) flake(f, true);
          // Wrap round the sides, so the wind never empties a side.
          if (f.x < -f.sway - f.r) f.x += w + f.sway * 2 + f.r * 2;
          else if (f.x > w + f.sway + f.r) f.x -= w + f.sway * 2 + f.r * 2;
        }
      }

      resize();
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          advance(dt);
          draw();
        }
      });
      // Pause while off screen to save battery.
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-snowfall]').forEach(function (el) {
        if (el._snf) return;
        el._snf = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-snowfall style="background: #0f1e3d; color: #fff">
  <h1>Season’s greetings</h1>
</section>