← Filters & retro

Watercolour Bloom

A photo appears as a watercolour painting, blooming out over blank paper from a few wet spots. The colour spreads with soft, ragged fronts, darker where the wet paint pools, then dries into soft washes with deeper edges where colours meet and the grain of the paper showing through.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-watercolour-bloom and give the wrapper a size. The image fills it, cropped to cover.
  3. Use an image from your own site (or an inline SVG): the script has to read its pixels.

Options

Add any of these attributes to the same element.

data-durationHow long the bloom takes. Default 4200 ms.
data-loopBloom, hold, fade back to blank paper and repeat.
data-holdHow long the finished painting stays when looping. Default 2600 ms.
data-seedsHow many wet spots it blooms from. Default 5.
data-paperThe paper colour. Default #faf6ee.

With reduced motion the finished painting shows straight away.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Watercolour Bloom: a photo appears as a watercolour painting, blooming
    // out over blank paper from a few wet spots. The colour spreads with soft,
    // ragged fronts that are darker where the wet paint pools, then dries into
    // a painting with soft washes, darker edges between colours and the grain
    // of the paper showing through.
    // Usage: <figure data-watercolour-bloom><img src="photo.jpg" alt="…"></figure>
    // The image (an <img> on the same site, or an inline <svg>) fills the box,
    // cropped to cover, and stays in the page for screen readers.
    // Optional: data-duration (ms for the bloom, default 4200), data-loop (bloom,
    // hold, fade to blank paper, repeat), data-hold (ms to hold the painting when
    // looping, default 2600), data-seeds (how many wet spots it blooms from,
    // default 5), data-paper (paper colour, default #faf6ee).
    var DEFAULTS = { duration: 4200, hold: 2600, seeds: 5, paper: '#faf6ee', maxSize: 720 };

    var css =
      '.wcb{position:relative;overflow:hidden}' +
      '.wcb-src{opacity:0}' +
      '.wcb-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function loadSource(node, done) {
      if (node.tagName.toLowerCase() === 'svg') {
        var vb = node.viewBox && node.viewBox.baseVal, r = node.getBoundingClientRect();
        var vw = (vb && vb.width) || r.width || 300, vh = (vb && vb.height) || r.height || 150;
        var k = 1000 / Math.max(vw, vh);
        var clone = node.cloneNode(true);
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        clone.setAttribute('width', Math.round(vw * k));
        clone.setAttribute('height', Math.round(vh * k));
        var im = new Image();
        im.onload = function () { done(im, im.width, im.height); };
        im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      } else if (node.complete && node.naturalWidth) {
        done(node, node.naturalWidth, node.naturalHeight);
      } else {
        node.addEventListener('load', function () { done(node, node.naturalWidth, node.naturalHeight); });
      }
    }

    function cover(ctx, img, iw, ih, w, h) {
      var s = Math.max(w / iw, h / ih);
      ctx.drawImage(img, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
    }

    function hex(c) {
      c = c.trim().replace('#', '');
      if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
      var n = parseInt(c, 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }

    // Smooth value noise, for wobbly washes, ragged fronts and paper grain.
    function hash(x, y, seed) {
      var n = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 1442695041);
      n = Math.imul(n ^ (n >>> 13), 1274126177);
      return ((n ^ (n >>> 16)) >>> 0) / 4294967295;
    }
    function noise(x, y, seed) {
      var xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
      var u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
      var a = hash(xi, yi, seed), b = hash(xi + 1, yi, seed), c = hash(xi, yi + 1, seed), d = hash(xi + 1, yi + 1, seed);
      return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
    }
    function fbm(x, y, seed) {
      return noise(x, y, seed) * 0.55 + noise(x * 2.1, y * 2.1, seed + 1) * 0.3 + noise(x * 4.3, y * 4.3, seed + 2) * 0.15;
    }

    // A blurred copy of the source pixels, by shrinking and growing again.
    function blurred(src, w, h, factor) {
      var small = document.createElement('canvas');
      small.width = Math.max(1, Math.round(w / factor));
      small.height = Math.max(1, Math.round(h / factor));
      var sctx = small.getContext('2d');
      sctx.imageSmoothingQuality = 'high';
      sctx.drawImage(src, 0, 0, small.width, small.height);
      var big = document.createElement('canvas');
      big.width = w; big.height = h;
      var bctx = big.getContext('2d', { willReadFrequently: true });
      bctx.imageSmoothingQuality = 'high';
      bctx.drawImage(small, 0, 0, w, h);
      return bctx.getImageData(0, 0, w, h).data;
    }

    function setup(el, img, iw, ih) {
      var d = el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var hold = +d.hold || DEFAULTS.hold;
      var nSeeds = Math.max(1, +d.seeds || DEFAULTS.seeds);
      var paper = hex(d.paper || DEFAULTS.paper);
      var canvas = document.createElement('canvas');
      canvas.className = 'wcb-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d', { willReadFrequently: true });
      var pw = 0, ph = 0, paint = null, bg = null, when = null, out = null;

      function build() {
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h) return false;
        var s = Math.min(Math.min(2, window.devicePixelRatio || 1), DEFAULTS.maxSize / Math.max(w, h));
        pw = Math.max(1, Math.round(w * s));
        ph = Math.max(1, Math.round(h * s));
        canvas.width = pw; canvas.height = ph;
        cover(ctx, img, iw, ih, pw, ph);
        var soft, softer;
        try {
          soft = blurred(canvas, pw, ph, 4);
          softer = blurred(canvas, pw, ph, 12);
        } catch (err) { canvas.remove(); el.querySelector('.wcb-src').classList.remove('wcb-src'); return false; }

        var n = pw * ph, unit = Math.min(pw, ph), sc = 5 / unit;
        paint = new Uint8ClampedArray(n * 3);
        bg = new Uint8ClampedArray(n * 3);
        when = new Float32Array(n);
        var seeds = [], i;
        for (i = 0; i < nSeeds; i++) {
          seeds.push([0.12 + hash(i, 1, 31) * 0.76, 0.15 + hash(i, 2, 31) * 0.7, hash(i, 3, 31) * 0.25]);
        }
        var maxT = 0;
        for (var y = 0; y < ph; y++) {
          for (var x = 0; x < pw; x++) {
            i = y * pw + x;
            // Wobble where each pixel takes its colour from, so washes bleed past the edges.
            var wx = Math.round(x + (fbm(x * sc, y * sc, 1) - 0.5) * unit * 0.05);
            var wy = Math.round(y + (fbm(x * sc, y * sc, 9) - 0.5) * unit * 0.05);
            wx = wx < 0 ? 0 : wx >= pw ? pw - 1 : wx;
            wy = wy < 0 ? 0 : wy >= ph ? ph - 1 : wy;
            var k = (wy * pw + wx) * 4;
            var r = soft[k], g = soft[k + 1], b = soft[k + 2];
            // Pigment pools where colours meet: darken where the two blurs disagree.
            var la = 0.3 * r + 0.59 * g + 0.11 * b;
            var lb = 0.3 * softer[k] + 0.59 * softer[k + 1] + 0.11 * softer[k + 2];
            var edge = Math.min(0.32, Math.abs(la - lb) / 255 * 2.4);
            // Thin it with water: lift towards white, and a touch more saturated.
            var m = (r + g + b) / 3;
            r = m + (r - m) * 1.15; g = m + (g - m) * 1.15; b = m + (b - m) * 1.15;
            var lift = 0.8 + fbm(x * sc * 0.7, y * sc * 0.7, 4) * 0.15;
            r = 255 - (255 - r) * lift; g = 255 - (255 - g) * lift; b = 255 - (255 - b) * lift;
            // Granulation and paper tooth.
            var tooth = 0.94 + noise(x * 0.9, y * 0.9, 6) * 0.06 - edge;
            paint[i * 3] = r * tooth * paper[0] / 255;
            paint[i * 3 + 1] = g * tooth * paper[1] / 255;
            paint[i * 3 + 2] = b * tooth * paper[2] / 255;
            var grain = 1 - noise(x * 0.9, y * 0.9, 6) * 0.04;
            bg[i * 3] = paper[0] * grain; bg[i * 3 + 1] = paper[1] * grain; bg[i * 3 + 2] = paper[2] * grain;
            // When the colour arrives: the nearest wet spot, with a ragged front.
            var t = 9;
            for (var q = 0; q < seeds.length; q++) {
              var dx = x / unit - seeds[q][0] * pw / unit, dy = y / unit - seeds[q][1] * ph / unit;
              var dist = Math.sqrt(dx * dx + dy * dy) + seeds[q][2];
              if (dist < t) t = dist;
            }
            t += fbm(x * sc * 1.4, y * sc * 1.4, 17) * 0.35;
            when[i] = t;
            if (t > maxT) maxT = t;
          }
        }
        for (i = 0; i < n; i++) when[i] /= maxT;
        out = ctx.createImageData(pw, ph);
        for (i = 0; i < n; i++) out.data[i * 4 + 3] = 255;
        return true;
      }

      // p runs 0 (blank paper) to 1 (finished painting); fade dims the whole painting for looping.
      function render(p, fade) {
        if (!out) return;
        var o = out.data, n = pw * ph, P = p * 1.25;
        for (var i = 0; i < n; i++) {
          var a = (P - when[i]) / 0.25;
          a = a <= 0 ? 0 : a >= 1 ? 1 : a;
          // The wet front: thin colour that is darker right at its edge.
          var front = a > 0 && a < 1 ? 4 * a * (1 - a) : 0;
          var c = Math.sqrt(a) * fade;
          var dark = 1 - front * 0.22 * fade;
          var j = i * 3, k = i * 4;
          o[k] = (bg[j] + (paint[j] - bg[j]) * c) * dark;
          o[k + 1] = (bg[j + 1] + (paint[j + 1] - bg[j + 1]) * c) * dark;
          o[k + 2] = (bg[j + 2] + (paint[j + 2] - bg[j + 2]) * c) * dark;
        }
        ctx.putImageData(out, 0, 0);
      }

      var state = { p: 0, fade: 1 };
      function draw() { render(state.p, state.fade); }
      if (!build()) return;
      if (window.ResizeObserver) {
        var lw = el.clientWidth, lh = el.clientHeight;
        new ResizeObserver(function () {
          if (el.clientWidth === lw && el.clientHeight === lh) return;
          lw = el.clientWidth; lh = el.clientHeight;
          if (build()) draw();
        }).observe(el);
      }
      if (reduced()) { state.p = 1; draw(); return; }
      draw();

      var loop = d.loop !== undefined && d.loop !== 'false';
      var anim = null;
      function play() {
        if (!loop) {
          anim = anime.animate(state, { p: 1, duration: duration, delay: 200, ease: 'out(1.6)', onUpdate: draw });
          return;
        }
        anim = anime.createTimeline({ loop: true, delay: 300 })
          .add(state, { p: 1, duration: duration, ease: 'out(1.6)', onUpdate: draw })
          .add(state, { fade: 0, duration: 900, delay: hold, ease: 'in(2)', onUpdate: draw })
          .add(state, { p: 0, duration: 1, onUpdate: draw })
          .add(state, { fade: 1, duration: 1, delay: 500, onUpdate: draw });
      }

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!anim) play();
          else if (loop) anim.play();
        } else if (anim && loop) anim.pause();
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-watercolour-bloom]').forEach(function (el) {
        if (el._wcb) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._wcb = true;
        el.classList.add('wcb');
        src.classList.add('wcb-src');
        loadSource(src, function (img, iw, ih) { setup(el, img, iw, ih); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-watercolour-bloom style="height:400px">
  <img src="photo.jpg" alt="Sand dunes at sunset">
</figure>