← Particles

Rainfall

Rain streaks down a section at a slight slant and splashes along the bottom, each drop throwing up a few droplets and a little ripple. Near drops are long and bright, far ones faint and fine.

Stay in

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-rainfall to a section with a dark background.

Options

Add any of these attributes to the same element.

data-densityDrops per 100 × 100 px. Default 2.2.
data-speedSpeed multiplier. Default 1.
data-windHow much the rain slants, from -1 (left) to 1 (right). Default 0.15.
data-colorColour of the rain. Default #c7dcff.
data-splash="false"No splashes: the rain falls past the bottom.

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

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Rainfall: rain streaks down a section at a slant and splashes at the
    // bottom, each drop throwing up a few droplets and a little ripple. Near
    // drops are longer, brighter and faster, far ones faint, and land a little
    // higher up, as if on the ground further back. Drawn on a transparent
    // canvas behind your content, and paused when off screen.
    // Usage: <section data-rainfall style="background:#111827">…</section>
    // Optional: data-density (drops per 100 × 100 px, default 2.2),
    // data-speed (default 1), data-wind (slant, -1 to 1, default 0.15),
    // data-color (default pale blue #c7dcff), data-splash="false" (no
    // splashes, for rain that falls past the bottom).
    var DEFAULTS = { density: 2.2, speed: 1, wind: 0.15, color: '#c7dcff' };

    var css =
      '[data-rainfall]{position:relative;isolation:isolate;overflow:hidden}' +
      '.rnf-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);

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'rnf-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 color = d.color || DEFAULTS.color;
      var splash = d.splash !== 'false';
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, unit = 1;
      var drops = [], bits = [], rings = [];

      // Where a drop lands: the bottom edge for near drops, a little higher
      // for far ones.
      function ground(z) { return splash ? h - 4 - (1 - z) * Math.min(26, h * 0.08) : h + 40; }

      function drop(p, anywhere) {
        p.z = Math.pow(Math.random(), 1.4);
        p.v = (900 + p.z * 700) * R(0.9, 1.1, 2);     // px/s
        p.len = (10 + p.z * 22) * R(0.8, 1.2, 2);
        p.width = 0.7 + p.z * 1.1;
        p.alpha = 0.18 + p.z * 0.5;
        p.land = ground(p.z);
        // Start far enough up and to the side that the slant still covers the box.
        p.x = Math.random() * (w + Math.abs(wind) * h) - (wind > 0 ? wind * h : 0);
        p.y = anywhere ? Math.random() * p.land : -p.len - Math.random() * h * 0.3;
        return p;
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        unit = Math.max(0.6, h / 500);
        var n = Math.round(Math.min(600, Math.max(40, (w * h * density) / 10000)));
        while (drops.length < n) drops.push(drop({}, true));
        drops.length = n;
        for (var i = 0; i < drops.length; i++) drops[i].land = ground(drops[i].z);
      }

      function land(p) {
        if (!splash) return;
        var x = p.x + wind * (p.land - p.y), y = p.land;
        rings.push({ x: x, y: y, age: 0, life: 0.35 + p.z * 0.25, size: 6 + p.z * 12, alpha: p.alpha });
        var n = p.z > 0.4 ? Math.round(R(2, 4)) : 1;
        for (var i = 0; i < n; i++) {
          var a = -Math.PI / 2 + R(-1.1, 1.1, 2);
          var v = R(60, 150) * (0.5 + p.z) * unit;
          bits.push({ x: x, y: y - 1, vx: Math.cos(a) * v + wind * 40, vy: Math.sin(a) * v, floor: y, age: 0, r: 0.6 + p.z * 0.9, alpha: p.alpha + 0.15 });
        }
      }

      function advance(dt) {
        dt *= speed;
        for (var i = 0; i < drops.length; i++) {
          var p = drops[i], dy = p.v * unit * dt;
          p.y += dy;
          p.x += wind * dy;
          if (p.y >= p.land) { land(p); drop(p, false); }
        }
        for (i = bits.length - 1; i >= 0; i--) {
          var b = bits[i];
          b.age += dt;
          b.vy += 900 * unit * dt;
          b.x += b.vx * dt;
          b.y += b.vy * dt;
          if (b.y > b.floor + 2 || b.age > 0.6) bits.splice(i, 1);
        }
        for (i = rings.length - 1; i >= 0; i--) {
          rings[i].age += dt;
          if (rings[i].age > rings[i].life) rings.splice(i, 1);
        }
        if (bits.length > 800) bits.splice(0, bits.length - 800);
        if (rings.length > 400) rings.splice(0, rings.length - 400);
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.strokeStyle = color;
        ctx.fillStyle = color;
        ctx.lineCap = 'round';
        // Streaks: a line along the direction of fall, tail behind the head.
        var len = Math.sqrt(1 + wind * wind);
        for (var i = 0; i < drops.length; i++) {
          var p = drops[i];
          if (p.y < -p.len) continue;
          ctx.globalAlpha = p.alpha;
          ctx.lineWidth = p.width;
          ctx.beginPath();
          ctx.moveTo(p.x - wind * p.len / len, p.y - p.len / len);
          ctx.lineTo(p.x, p.y);
          ctx.stroke();
        }
        // Ripples: flat ellipses that spread and fade.
        ctx.lineWidth = 1;
        for (i = 0; i < rings.length; i++) {
          var r = rings[i], k = r.age / r.life;
          ctx.globalAlpha = r.alpha * (1 - k) * 0.9;
          ctx.beginPath();
          ctx.ellipse(r.x, r.y, r.size * (0.2 + k), r.size * (0.2 + k) * 0.22, 0, 0, Math.PI * 2);
          ctx.stroke();
        }
        // Droplets thrown up by the splash.
        for (i = 0; i < bits.length; i++) {
          var b = bits[i];
          ctx.globalAlpha = Math.min(1, b.alpha) * (1 - b.age / 0.6);
          ctx.beginPath();
          ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.globalAlpha = 1;
      }

      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) / 1000 : 1 / 60;
          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-rainfall]').forEach(function (el) {
        if (el._rnf) return;
        el._rnf = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-rainfall style="background: #111827; color: #fff">
  <h1>Stay in</h1>
</section>