← Backgrounds

Lightning Storm

Dark clouds roll along the top of a section while rain lashes down, and every few seconds a forked bolt of lightning cracks down to the ground, flickering as it lights up the clouds and the whole sky.

Feel the power

How to use it

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

Options

Add any of these attributes to the same element.

data-intervalAverage time between strikes. Default 2600 ms.
data-colorColour of the lightning and its glow. Default pale blue #c7d2fe.
data-cloudsCloud colour. Default #3b3758.
data-rain="false"No rain.

Every bolt is new: a random jagged path with a few branches. Now and then the flash stays hidden inside the clouds. It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion there is no lightning, just the still clouds and rain, so nothing flashes.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Lightning Storm: dark clouds roll slowly across the top of a section
    // while rain falls, and every few seconds a forked bolt of lightning
    // strikes, lighting up the clouds and the whole sky with a flicker. Drawn
    // on a transparent canvas behind your content, and paused when off screen.
    // Usage: <section data-lightning-storm style="background:linear-gradient(#0f0c1d,#1e1b3a)">…</section>
    // Optional: data-interval (average ms between strikes, default 2600),
    // data-color (colour of the lightning, default pale blue #c7d2fe),
    // data-clouds (cloud colour, default #3b3758), data-rain="false" (no rain).
    var DEFAULTS = { interval: 2600, color: '#c7d2fe', clouds: '#3b3758' };

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

    var TAU = Math.PI * 2;
    var probe = document.createElement('canvas').getContext('2d');
    function rgb(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c;
      var v = probe.fillStyle;
      if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
      return v.match(/[\d.]+/g).slice(0, 3).map(Number);
    }
    function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }

    // One storm cloud, painted once on its own canvas: lumpy puffs, darker
    // underneath.
    function cloudSprite(ch, col, scale) {
      var R = anime.utils.random;
      var cw = ch * 2.4;
      var c = document.createElement('canvas');
      c.width = Math.ceil(cw * scale);
      c.height = Math.ceil(ch * scale);
      var g = c.getContext('2d');
      g.scale(scale, scale);
      var lit = [Math.min(255, col[0] + 28), Math.min(255, col[1] + 26), Math.min(255, col[2] + 34)];
      var dark = [col[0] * 0.55, col[1] * 0.55, col[2] * 0.6].map(Math.round);
      for (var i = 0; i < 26; i++) {
        var r = ch * R(0.1, 0.24, 3);
        var x = R(r, cw - r, 1), y = R(ch * 0.2 + r * 0.5, ch - r, 1);
        var gr = g.createRadialGradient(x, y, 0, x, y, r);
        gr.addColorStop(0, rgba(i % 3 ? col : lit, 0.95));
        gr.addColorStop(0.65, rgba(col, 0.8));
        gr.addColorStop(1, rgba(col, 0));
        g.fillStyle = gr;
        g.beginPath();
        g.arc(x, y, r, 0, TAU);
        g.fill();
      }
      g.globalCompositeOperation = 'source-atop';
      var sh = g.createLinearGradient(0, 0, 0, ch);
      sh.addColorStop(0.3, rgba(dark, 0));
      sh.addColorStop(1, rgba(dark, 0.85));
      g.fillStyle = sh;
      g.fillRect(0, 0, cw, ch);
      return { img: c, w: cw, h: ch };
    }

    // A jagged line from a to b: split each segment in two and nudge the
    // middle sideways, again and again, with smaller nudges each time.
    function jag(x1, y1, x2, y2, disp, iter) {
      var pts = [[x1, y1], [x2, y2]];
      for (var k = 0; k < iter; k++) {
        var np = [pts[0]];
        for (var i = 0; i < pts.length - 1; i++) {
          var a = pts[i], b = pts[i + 1];
          var dx = b[0] - a[0], dy = b[1] - a[1], len = Math.hypot(dx, dy) || 1;
          var off = (Math.random() * 2 - 1) * disp;
          np.push([(a[0] + b[0]) / 2 - (dy / len) * off, (a[1] + b[1]) / 2 + (dx / len) * off], b);
        }
        pts = np;
        disp *= 0.55;
      }
      return pts;
    }

    function build(el) {
      var d = el.dataset;
      var bolt = rgb(d.color || DEFAULTS.color);
      var cloudCol = rgb(d.clouds || DEFAULTS.clouds);
      var cloudDark = cloudCol.map(function (v) { return Math.round(v * 0.7); });
      var interval = +d.interval || DEFAULTS.interval;
      var rainy = d.rain !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'ltn-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, clouds = [], rain = [];
      var strike = null, next = 700, clock = 0;
      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        var nd = Math.min(2, window.devicePixelRatio || 1);
        if (nw === w && nh === h && nd === dpr && clouds.length) return;
        w = nw; h = nh; dpr = nd;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        // A thick bank of cloud along the top, two layers deep.
        clouds = [];
        var ch = Math.max(70, Math.min(h * 0.5, w * 0.35));
        [0, 1].forEach(function (layer) {
          var size = ch * (layer ? 1 : 0.8);
          var n = Math.ceil(w / (size * 1.5)) + 2;
          for (var i = 0; i < n; i++) {
            var s = cloudSprite(size * R(0.85, 1.15, 2), cloudCol, dpr);
            clouds.push({ s: s, x: (i - 1) * size * 1.5 + R(-20, 20), y: -s.h * (layer ? 0.42 : 0.55) + R(-10, 10) + layer * h * 0.04, v: layer ? 9 : 5, span: n * size * 1.5 });
          }
        });
        var n = Math.round(Math.min(320, (w * h) / 2600));
        rain = [];
        for (var j = 0; j < n; j++) rain.push({ x: R(0, w, 1), y: R(0, h, 1), l: R(8, 18, 1), v: R(500, 800) });
        draw();
      }

      // Each strike flickers: a few bright pulses that quickly die away.
      function envelope(age) {
        if (!strike) return 0;
        var e = 0;
        for (var i = 0; i < strike.pulses.length; i++) {
          var p = strike.pulses[i];
          if (age >= p[0]) e = Math.max(e, p[1] * Math.exp(-(age - p[0]) / 110));
        }
        return e;
      }

      function newStrike() {
        var x0 = R(0.12, 0.88, 3) * w;
        var y0 = h * R(0.06, 0.16, 3);
        var x1 = x0 + R(-0.22, 0.22, 3) * w;
        var main = jag(x0, y0, x1, h * R(0.85, 1.05, 3), h * 0.16, 6);
        var paths = [{ pts: main, wd: 1 }];
        var branches = R(2, 4);
        for (var b = 0; b < branches; b++) {
          var s = main[R(6, Math.floor(main.length * 0.65))];
          var side = Math.random() < 0.5 ? -1 : 1;
          paths.push({ pts: jag(s[0], s[1], s[0] + side * R(0.08, 0.22, 3) * w, s[1] + R(0.12, 0.3, 3) * h, h * 0.07, 5), wd: 0.5 });
        }
        // Now and then: only a flash deep inside the clouds, no bolt.
        var sheet = Math.random() < 0.25;
        return {
          x: x0, y: y0, paths: sheet ? [] : paths, power: sheet ? 0.5 : 1,
          pulses: [[0, 1], [R(60, 110), R(0.5, 0.85, 2)], [R(160, 260), R(0.4, 0.9, 2)]],
          age: 0
        };
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.clearRect(0, 0, w, h);
        var f = strike ? envelope(strike.age) * strike.power : 0;
        // The whole sky lights up a little.
        if (f > 0.01) {
          ctx.fillStyle = rgba(bolt, (f * 0.16).toFixed(3));
          ctx.fillRect(0, 0, w, h);
        }
        if (rainy) {
          ctx.strokeStyle = 'rgba(200,210,235,' + (0.22 + f * 0.4).toFixed(3) + ')';
          ctx.lineWidth = 1;
          ctx.beginPath();
          for (var i = 0; i < rain.length; i++) {
            var p = rain[i];
            ctx.moveTo(p.x, p.y);
            ctx.lineTo(p.x - p.l * 0.2, p.y + p.l);
          }
          ctx.stroke();
        }
        if (f > 0.01) {
          ctx.globalCompositeOperation = 'lighter';
          ctx.lineJoin = 'round';
          ctx.lineCap = 'round';
          var unit = Math.max(0.7, Math.min(1.3, Math.min(w, h) / 360));
          strike.paths.forEach(function (p) {
            [[7, 0.18], [3, 0.55]].forEach(function (pass) {
              ctx.strokeStyle = rgba(bolt, (f * pass[1]).toFixed(3));
              ctx.lineWidth = pass[0] * p.wd * unit;
              ctx.beginPath();
              ctx.moveTo(p.pts[0][0], p.pts[0][1]);
              for (var k = 1; k < p.pts.length; k++) ctx.lineTo(p.pts[k][0], p.pts[k][1]);
              ctx.stroke();
            });
            ctx.strokeStyle = 'rgba(255,255,255,' + Math.min(1, f * 1.2).toFixed(3) + ')';
            ctx.lineWidth = 1.2 * p.wd * unit;
            ctx.stroke();
          });
          ctx.globalCompositeOperation = 'source-over';
        }
        // A solid ceiling of cloud behind the lumpy ones.
        var top = ctx.createLinearGradient(0, 0, 0, h * 0.3);
        top.addColorStop(0, rgba(cloudDark, 1));
        top.addColorStop(1, rgba(cloudDark, 0));
        ctx.fillStyle = top;
        ctx.fillRect(0, 0, w, h * 0.3);
        for (var c = 0; c < clouds.length; c++) {
          var cl = clouds[c];
          ctx.drawImage(cl.s.img, cl.x, cl.y, cl.s.w, cl.s.h);
        }
        // The clouds glow from inside where the bolt leaves them.
        if (f > 0.01) {
          ctx.globalCompositeOperation = 'lighter';
          var rad = Math.max(w, h) * 0.55;
          var g = ctx.createRadialGradient(strike.x, strike.y, 0, strike.x, strike.y, rad);
          g.addColorStop(0, rgba(bolt, (f * 0.55).toFixed(3)));
          g.addColorStop(0.4, rgba(bolt, (f * 0.12).toFixed(3)));
          g.addColorStop(1, rgba(bolt, 0));
          ctx.fillStyle = g;
          ctx.fillRect(0, 0, w, h);
          ctx.globalCompositeOperation = 'source-over';
        }
      }

      function advance(dt) {
        clock += dt;
        if (strike) {
          strike.age += dt;
          if (strike.age > 900) strike = null;
        }
        if (clock >= next) {
          strike = newStrike();
          clock = 0;
          next = interval * R(0.5, 1.5, 2);
        }
        // A solid ceiling of cloud behind the lumpy ones.
        var top = ctx.createLinearGradient(0, 0, 0, h * 0.3);
        top.addColorStop(0, rgba(cloudDark, 1));
        top.addColorStop(1, rgba(cloudDark, 0));
        ctx.fillStyle = top;
        ctx.fillRect(0, 0, w, h * 0.3);
        for (var c = 0; c < clouds.length; c++) {
          var cl = clouds[c];
          cl.x += (cl.v * dt) / 1000;
          if (cl.x > w + 20) cl.x -= cl.span;
        }
        for (var i = 0; i < rain.length; i++) {
          var p = rain[i];
          p.y += (p.v * dt) / 1000;
          p.x -= (p.v * 0.2 * dt) / 1000;
          if (p.y > h) { p.y = -p.l - R(0, 40); p.x = R(0, w + h * 0.2, 1); }
        }
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (still) return;

      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();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-lightning-storm style="background: linear-gradient(#0f0c1d, #1e1b3a); color: #fff">
  <h1>Feel the power</h1>
</section>