← Backgrounds

Water Caustics

The rippling net of light you see on the bottom of a swimming pool on a sunny day, endlessly shifting and re-forming. Add faint tiles for the full poolside feel.

Summer starts here

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-water-caustics to a section, hero or card.

Options

Add any of these attributes to the same element.

data-colorsTwo colours: the water, then the light. Default #0e7490,#ecfeff.
data-scaleSize of the pattern. Default 1.
data-speedSpeed multiplier. Default 1.
data-tilesFaint pool tiles over the light. Give a size in px, e.g. data-tiles="40". Default 48 when set.

It fills the section on its own, so no background is needed. It’s worked out on a small canvas and scaled up smoothly, so it costs about the same on any screen, and it pauses 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 () {
    // Water Caustics: the rippling net of light you see on the bottom of a
    // swimming pool, endlessly shifting. Drawn small on a canvas and scaled
    // up behind your content, and paused when off screen.
    // Usage: <section data-water-caustics>…</section>
    // Optional: data-colors (two colours: the water, then the light, default
    // "#0e7490,#ecfeff"), data-scale (size of the pattern, default 1),
    // data-speed (default 1), data-tiles (draw faint pool tiles; give a size in
    // px, e.g. data-tiles="40", default 48 when set).
    var DEFAULTS = { colors: '#0e7490,#ecfeff', scale: 1, speed: 1, tiles: 48 };

    var css =
      '[data-water-caustics]{position:relative;isolation:isolate;overflow:hidden}' +
      '.wcs-canvas,.wcs-tiles{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}' +
      '.wcs-tiles{background-image:linear-gradient(rgba(255,255,255,.1) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.1) 1px,transparent 1px);mix-blend-mode:overlay}';
    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.trim();
      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);
    }

    // Keep the work per frame about the same, whatever the section's size.
    var PIXELS = 15000;

    function build(el) {
      var d = el.dataset;
      var cols = (d.colors || DEFAULTS.colors).split(',');
      var deep = rgb(cols[0]), light = rgb(cols[1] || '#ffffff');
      var scale = d.scale !== undefined ? +d.scale : DEFAULTS.scale;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var canvas = document.createElement('canvas');
      canvas.className = 'wcs-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      if (d.tiles !== undefined) {
        var tiles = document.createElement('div');
        tiles.className = 'wcs-tiles';
        tiles.setAttribute('aria-hidden', 'true');
        var ts = (+d.tiles || DEFAULTS.tiles) + 'px';
        tiles.style.backgroundSize = ts + ' ' + ts;
        canvas.after(tiles);
      }
      var ctx = canvas.getContext('2d');
      var cw = 0, ch = 0, cell = 4, img = null, t = 0;
      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      // A brightness table: the light gathers into thin, bright lines.
      var lut = [];
      for (var i = 0; i < 256; i++) {
        var v = i / 255;
        lut.push([0, 1, 2].map(function (k) { return Math.round(deep[k] + (light[k] - deep[k]) * v); }));
      }

      function resize() {
        var w = el.clientWidth, h = el.clientHeight;
        cell = Math.max(2, Math.sqrt((w * h) / PIXELS));
        cw = Math.max(1, Math.round(w / cell));
        ch = Math.max(1, Math.round(h / cell));
        canvas.width = cw;
        canvas.height = ch;
        img = ctx.createImageData(cw, ch);
        draw();
      }

      // The classic caustic trick: each point is pushed around by a few
      // layers of swirling sine waves, and where the paths bunch up the light
      // is bright.
      function draw() {
        var data = img.data, time = t * 0.5 + 23, inten = 0.005;
        // The pattern repeats every few hundred px; turning it a little hides
        // the repeat.
        var unit = (Math.PI * 2) / (380 * scale), ca = Math.cos(0.35) * unit, sa = Math.sin(0.35) * unit;
        var tt = [];
        for (var n = 0; n < 5; n++) tt.push(time * (1 - 3.5 / (n + 1)));
        var o = 0;
        for (var y = 0; y < ch; y++) {
          var yy = y * cell;
          for (var x = 0; x < cw; x++) {
            var xx = x * cell;
            var px = xx * ca - yy * sa - 250, py = xx * sa + yy * ca - 250;
            var ix = px, iy = py, c = 1;
            for (n = 0; n < 5; n++) {
              var s = tt[n];
              var nx = px + Math.cos(s - ix) + Math.sin(s + iy);
              var ny = py + Math.sin(s - iy) + Math.cos(s + ix);
              ix = nx; iy = ny;
              var ax = (px * inten) / Math.sin(ix + s), ay = (py * inten) / Math.cos(iy + s);
              c += 1 / Math.sqrt(ax * ax + ay * ay);
            }
            c = 1.17 - Math.pow(c / 5, 1.4);
            var v = Math.pow(Math.abs(c), 8);
            var col = lut[v >= 1 ? 255 : (v * 255) | 0];
            data[o] = col[0]; data[o + 1] = col[1]; data[o + 2] = col[2]; data[o + 3] = 255;
            o += 4;
          }
        }
        ctx.putImageData(img, 0, 0);
      }

      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;
          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-water-caustics]').forEach(function (el) {
        if (el._wcs) return;
        el._wcs = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-water-caustics data-tiles style="color: #fff">
  <h1>Summer starts here</h1>
</section>