← Backgrounds

Paint Swirl

Bands of colour swirl, stretch and fold into each other, like paint stirred into water or the pattern on marbled paper. Give it your own colours and it keeps them in order as it stirs.

Stir it up

How to use it

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

Options

Add any of these attributes to the same element.

data-colorsComma-separated colours, any number. Default navy, coral, cream, teal and violet.
data-speedSpeed multiplier. Default 1.
data-scaleSize of the swirls. Default 1.

It’s drawn small on a canvas and scaled up, 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 () {
    // Paint Swirl: bands of colour swirl, stretch and fold into each other,
    // like paint stirred into water or marbled paper. Drawn small on a canvas
    // behind your content (it's all soft curves, so it scales up cleanly),
    // and paused when off screen.
    // Usage: <section data-paint-swirl>…</section>
    // Optional: data-colors (comma-separated, any number, default navy, coral,
    // cream, teal and violet), data-speed (default 1), data-scale (size of
    // the swirls, default 1).
    var DEFAULTS = { colors: ['#1e3a8a', '#fb7185', '#fef3c7', '#14b8a6', '#7c3aed'], speed: 1, scale: 1 };
    var MAX_PIXELS = 48000;

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

    // A looping palette of 512 steps: each paint holds its colour for a
    // while, then blends quickly into the next, so the bands stay distinct.
    function palette(colors) {
      var n = colors.length, lut = new Uint8ClampedArray(512 * 3);
      for (var i = 0; i < 512; i++) {
        var p = (i / 512) * n, a = Math.floor(p), f = p - a;
        f = f < 0.55 ? 0 : (f - 0.55) / 0.45;
        f = f * f * (3 - 2 * f);
        var c1 = colors[a % n], c2 = colors[(a + 1) % n];
        for (var k = 0; k < 3; k++) lut[i * 3 + k] = c1[k] + (c2[k] - c1[k]) * f;
      }
      return lut;
    }

    function build(el) {
      var d = el.dataset;
      var colors = (d.colors ? d.colors.split(',') : DEFAULTS.colors).map(function (c) { return rgb(c.trim()); });
      var lut = palette(colors);
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var scale = +d.scale || DEFAULTS.scale;
      var canvas = document.createElement('canvas');
      canvas.className = 'pns-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var w = 0, h = 0, step = 3, img = null, t = 5;

      function resize() {
        var cw = Math.max(1, el.clientWidth), ch = Math.max(1, el.clientHeight);
        step = Math.max(3, 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);
        draw();
      }

      function draw() {
        var data = img.data;
        var S = (170 * scale) / step;   // about 170 css px per unit
        var cx = w / 2, cy = h / 2;
        for (var y = 0; y < h; y++) {
          for (var x = 0; x < w; x++) {
            var px = (x - cx) / S, py = (y - cy) / S;
            // Stir: each pass pushes the point along a wave made by the last,
            // which folds the bands over and over.
            for (var i = 1; i < 5; i++) {
              var nx = px + (0.75 / i) * Math.sin(i * py * 1.1 + t * 0.35 + i * 1.7);
              py = py + (0.75 / i) * Math.cos(i * px * 0.9 - t * 0.28 + i * 0.9);
              px = nx;
            }
            var v = 0.5 * Math.sin(px * 0.9 + py * 0.7 + t * 0.1) + 0.5 * Math.cos(py * 1.3 - px * 0.4);
            var idx = Math.floor(((v * 0.75 + 1.5 + t * 0.015) % 1) * 512) * 3;
            var o = (y * w + x) * 4;
            data[o] = lut[idx];
            data[o + 1] = lut[idx + 1];
            data[o + 2] = lut[idx + 2];
            data[o + 3] = 255;
          }
        }
        ctx.putImageData(img, 0, 0);
      }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-paint-swirl data-colors="#0f172a,#f97316,#fef3c7,#0ea5e9" style="color: #fff">
  <h1>Stir it up</h1>
</section>