← Backgrounds

Lava Lamp

Glowing blobs of wax rise from a warm pool at the bottom of a section, stretch as they go, merge with each other and split apart again, then rest at the top before sinking back down. Groovy.

Far out

How to use it

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

Options

Add any of these attributes to the same element.

data-colorsTwo colours for the wax, top then bottom. Default pink #f472b6 and orange #fb923c.
data-countNumber of blobs. Default about two for each box height of width, 4 to 14.
data-sizeBlob size multiplier. Default 1.
data-speedSpeed multiplier. Default 1.

It’s drawn on a small transparent canvas behind your content and paused when off screen. With reduced motion it shows one still frame.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Lava Lamp: glowing blobs of wax rise from a warm pool at the bottom,
    // stretch, merge and split, cool at the top and sink back down. Drawn on
    // a transparent canvas behind your content, and paused when off screen.
    // Usage: <section data-lava-lamp style="background:#2e1065">…</section>
    // Optional: data-colors (two colours, top then bottom, default pink
    // #f472b6 and orange #fb923c), data-count (number of blobs, default
    // about 2 per box height of width, 4 to 14), data-speed (default 1),
    // data-size (blob size multiplier, default 1).
    var DEFAULTS = { colors: ['#f472b6', '#fb923c'], speed: 1, size: 1 };
    var MAX_PIXELS = 90000;

    var css =
      '[data-lava-lamp]{position:relative;isolation:isolate;overflow:hidden}' +
      '.lva-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 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 build(el) {
      var d = el.dataset;
      var cols = (d.colors ? d.colors.split(',') : DEFAULTS.colors).map(function (c) { return rgb(c.trim()); });
      var top = cols[0], bottom = cols[1] || cols[0];
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var size = +d.size || DEFAULTS.size;
      var canvas = document.createElement('canvas');
      canvas.className = 'lva-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, step = 2, img = null, t = 0;
      var blobs = [], tint = [];

      function blob(i, n) {
        return {
          home: (i + 0.5) / n + R(-0.25, 0.25, 2) / n,  // across, as a fraction
          r: R(0.75, 1.25, 2),
          f: R(0.1, 0.2, 3), ph: R(0, 6.28, 2),         // rise-and-fall rhythm
          wf: R(0.2, 0.5, 2), wph: R(0, 6.28, 2),       // side-to-side wobble
          x: 0, y: 0
        };
      }

      function resize() {
        var cw = Math.max(1, el.clientWidth), ch = Math.max(1, el.clientHeight);
        step = Math.max(1.5, Math.sqrt((cw * ch) / MAX_PIXELS));
        w = Math.ceil(cw / step);
        h = Math.ceil(ch / step);
        canvas.width = w;
        canvas.height = h;
        img = ctx.createImageData(w, h);
        var n = +d.count || Math.round(Math.min(14, Math.max(4, (cw / ch) * 1.8 + 1.5)));
        if (blobs.length !== n) {
          blobs = [];
          for (var i = 0; i < n; i++) blobs.push(blob(i, n));
        }
        // The wax colour fades from the bottom colour to the top colour.
        tint = [];
        for (var y = 0; y < h; y++) {
          var k = y / Math.max(1, h - 1);
          tint.push([top[0] + (bottom[0] - top[0]) * k, top[1] + (bottom[1] - top[1]) * k, top[2] + (bottom[2] - top[2]) * k]);
        }
        draw();
      }

      function place() {
        var base = Math.min(w, h * 1.4) * 0.115 * size;
        for (var i = 0; i < blobs.length; i++) {
          var b = blobs[i];
          // Mostly lingering near the pool or the top, travelling in between.
          var s = Math.sin(t * b.f + b.ph);
          s = s * (1.5 - 0.5 * s * s);
          b.y = h * (0.52 - 0.56 * s);
          b.x = w * b.home + Math.sin(t * b.wf + b.wph) * w * 0.04;
          // Blobs stretch taller while travelling, rounder when they rest.
          b.rr = base * b.r;
          b.sy = 1 + 0.35 * (1 - s * s);
        }
      }

      function draw() {
        place();
        var data = img.data, n = blobs.length;
        var pool = h * 0.075 * size, cap = h * 0.035 * size;
        for (var y = 0; y < h; y++) {
          // The warm pool at the bottom and a thin cool layer at the top.
          var db = h + pool * 0.35 - y, dt = y + cap * 0.6;
          var edge = (pool * pool) / (db * db) + (cap * cap) / (dt * dt);
          var c = tint[y];
          for (var x = 0; x < w; x++) {
            var f = edge;
            for (var k = 0; k < n; k++) {
              var b = blobs[k], dx = x - b.x, dy = (y - b.y) / b.sy;
              f += (b.rr * b.rr) / (dx * dx + dy * dy + 1);
            }
            var o = (y * w + x) * 4;
            if (f < 0.95) { data[o + 3] = 0; continue; }
            // A crisp but smooth edge, and the wax a little paler inside.
            var a = f > 1.05 ? 1 : (f - 0.95) / 0.1;
            var glow = (1 - 1 / f) * 0.32;
            data[o] = c[0] + (255 - c[0]) * glow;
            data[o + 1] = c[1] + (255 - c[1]) * glow;
            data[o + 2] = c[2] + (255 - c[2]) * glow;
            data[o + 3] = a * 255;
          }
        }
        ctx.putImageData(img, 0, 0);
      }

      t = 20;
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) 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;
          t += (dt / 1000) * speed;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-lava-lamp style="background: linear-gradient(#2e1065, #1e1b4b); color: #fff">
  <h1>Far out</h1>
</section>