← Backgrounds

Rolling Fog

Layers of soft mist roll sideways behind a section. The far layers sit high, thin and slow; the near ones low, thick and quicker, so the fog seems to drift past in depth.

Into the mist

How to use it

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

Options

Add any of these attributes to the same element.

data-colorColour of the mist. Default pale grey #e2e8f0.
data-layersHow many layers of mist. Default 4.
data-densityHow thick the mist is, as a multiplier. Default 1.
data-speedSpeed multiplier. Default 1.
data-directionleft or right. Default right.

Each layer is painted once, at half size, and then just slid along, so it costs very little. It pauses when off screen. With reduced motion the mist holds still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Rolling Fog: layers of soft mist drift sideways behind a section, the
    // near ones lower, thicker and faster than the far ones, so the fog seems
    // to roll past in depth. Drawn on a transparent canvas behind your content,
    // and paused when off screen.
    // Usage: <section data-rolling-fog style="background:#1e293b">…</section>
    // Optional: data-color (mist colour, default #e2e8f0), data-layers (how
    // many layers, default 4), data-density (how thick, as a multiplier,
    // default 1), data-speed (default 1), data-direction ("left" or "right",
    // default right).
    var DEFAULTS = { color: '#e2e8f0', layers: 4, density: 1, speed: 1 };

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

    // A roughly bell-shaped random number around 0.
    function bell() { return (Math.random() + Math.random() + Math.random()) / 1.5 - 1; }

    function build(el) {
      var d = el.dataset;
      var col = rgb(d.color || DEFAULTS.color).join(',');
      var count = Math.max(1, Math.min(8, +d.layers || DEFAULTS.layers));
      var density = d.density !== undefined ? +d.density : DEFAULTS.density;
      var speed = (d.speed !== undefined ? +d.speed : DEFAULTS.speed) * (d.direction === 'left' ? -1 : 1);
      var canvas = document.createElement('canvas');
      canvas.className = 'rfg-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, layers = [], t = 0;
      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      // The mist is soft, so each layer is painted at half size and scaled up.
      var LO = 0.5;

      // Paint one layer of mist into its own canvas, a strip that tiles
      // seamlessly from side to side.
      function paint(i) {
        var depth = count > 1 ? i / (count - 1) : 1; // 0 far, 1 near
        var tw = Math.max(w, 480) * (1.2 + depth * 0.3);
        var bandY = h * (0.42 + depth * 0.4);
        var bandH = h * (0.35 + depth * 0.25);
        var top = Math.max(0, bandY - bandH), bottom = Math.min(h * 1.15, bandY + bandH);
        var c = document.createElement('canvas');
        c.width = Math.ceil(tw * LO);
        c.height = Math.ceil((bottom - top) * LO);
        var g = c.getContext('2d');
        g.scale(LO, LO);
        var scale = 0.7 + depth * 0.7;
        var n = Math.round((tw / 22) * density);
        for (var k = 0; k < n; k++) {
          var r = R(50, 130) * scale * Math.min(1, h / 360 + 0.3);
          var x = R(0, tw, 1);
          var y = bandY + bell() * bandH * 0.45 - top;
          var a = R(4, 10) / 100 * (0.6 + depth * 0.5);
          // Draw at x and its neighbours either side so the strip wraps.
          for (var s = -1; s <= 1; s++) {
            var cx = x + s * tw;
            if (cx + r < 0 || cx - r > tw) continue;
            var rg = g.createRadialGradient(cx, y, 0, cx, y, r);
            rg.addColorStop(0, 'rgba(' + col + ',' + a.toFixed(3) + ')');
            rg.addColorStop(1, 'rgba(' + col + ',0)');
            g.fillStyle = rg;
            g.beginPath();
            g.ellipse(cx, y, r, r * 0.55, 0, 0, TAU);
            g.fill();
          }
        }
        // A light blur smooths away the banding of so many faint layers.
        var soft = document.createElement('canvas');
        soft.width = c.width;
        soft.height = c.height;
        var sg = soft.getContext('2d');
        if ('filter' in sg) {
          sg.filter = 'blur(3px)';
          // Blur the strip with copies either side, so its ends still meet.
          sg.drawImage(c, -c.width, 0);
          sg.drawImage(c, c.width, 0);
          sg.drawImage(c, 0, 0);
          c = soft;
        }
        return { canvas: c, tw: tw, top: top, height: bottom - top, v: (8 + depth * 30), bob: R(0, TAU, 2), x: R(0, tw, 1) };
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h && layers.length) return;
        w = nw;
        h = nh;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        layers = [];
        for (var i = 0; i < count; i++) layers.push(paint(i));
        draw();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        for (var i = 0; i < layers.length; i++) {
          var L = layers[i];
          var off = ((L.x + t * L.v * speed) % L.tw + L.tw) % L.tw;
          var y = L.top + Math.sin(t * 0.25 + L.bob) * h * 0.02;
          for (var x = off - L.tw; x < w; x += L.tw) {
            ctx.drawImage(L.canvas, x, y, L.tw, L.height);
          }
        }
      }

      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();
          t += (last ? Math.min(50, now - last) : 16) / 1000;
          last = now;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-rolling-fog style="background: #1e293b; color: #fff">
  <h1>Into the mist</h1>
</section>