← Particles

Ink in Water

Drops of coloured ink fall into clear water and bloom. Each one sinks, curls into a soft mushroom of tendrils and drifts off on slow currents, mixing with the others before it fades. Click or tap to drop ink anywhere.

Tap to drop ink

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-ink-in-water to a section with a pale background. Add data-autoplay to have drops fall by themselves while it’s in view.

Options

Add any of these attributes to the same element.

data-autoplayDrop ink by itself every couple of seconds. It waits a moment after a real click.
data-rateTime between drops with autoplay. Default 1800 ms.
data-lifeHow long each drop takes to fade. Default 9000 ms.
data-colorsComma-separated ink colours, used in turn.

The ink is mixed by multiplying colours, so it looks best on white or a pale tint, and darkens where drops overlap like real ink. It’s drawn on a canvas behind your content and paused when off screen. With reduced motion you see two blooms, still. From code, animacious.inkDrop(section, x, y) drops ink at a point.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Ink in Water: drops of coloured ink fall into a pale, watery section and
    // bloom: each one sinks, curls into a mushroom of soft tendrils and swirls
    // away on slow currents, mixing with the others before it fades. Click or
    // tap to drop ink at that spot. Drawn on a canvas behind your content, and
    // paused when off screen.
    // Usage: <section data-ink-in-water style="background:#f1f5f9">…</section>
    // Optional: data-autoplay (drop ink by itself every couple of seconds while
    // in view), data-rate (ms between drops with autoplay, default 1800),
    // data-colors (comma-separated ink colours, used in turn), data-life (how
    // long each drop takes to fade, ms, default 9000).
    // Script use: animacious.inkDrop(element, x, y) drops ink at a point (px
    // from the element's top-left; somewhere near the top if left out).
    var DEFAULTS = { rate: 1800, life: 9000, colors: ['#7c3aed', '#db2777', '#0891b2', '#ea580c', '#4f46e5', '#0d9488'] };

    var css =
      '[data-ink-in-water]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
      '.iiw-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);

    // Smooth value noise, for the slow currents in the water.
    var perm = [];
    for (var i = 0; i < 512; i++) perm[i] = Math.random();
    function hash(x, y) { return perm[((x * 73856093) ^ (y * 19349663)) & 511]; }
    function noise(x, y) {
      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), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
      return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
    }

    function blob(color) {
      var c = document.createElement('canvas');
      c.width = c.height = 64;
      var x = c.getContext('2d');
      var g = x.createRadialGradient(32, 32, 0, 32, 32, 32);
      g.addColorStop(0, color);
      g.addColorStop(0.45, color);
      g.addColorStop(1, 'rgba(255,255,255,0)');
      x.fillStyle = g;
      x.globalAlpha = 1;
      x.fillRect(0, 0, 64, 64);
      // Fade the edge to transparent, keeping the colour.
      x.globalCompositeOperation = 'destination-in';
      var m = x.createRadialGradient(32, 32, 0, 32, 32, 32);
      m.addColorStop(0, 'rgba(0,0,0,1)');
      m.addColorStop(0.5, 'rgba(0,0,0,.6)');
      m.addColorStop(1, 'rgba(0,0,0,0)');
      x.fillStyle = m;
      x.fillRect(0, 0, 64, 64);
      return c;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'iiw-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var rate = +d.rate || DEFAULTS.rate;
      var life = +d.life || DEFAULTS.life;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var sprites = colors.map(blob);
      var w = 0, h = 0, dpr = 1, t = 0, scale = 1, nextDrop = 300, colorIndex = 0, quietUntil = 0;
      var drops = [], parts = [];

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        scale = Math.max(0.55, Math.min(1.3, Math.min(w, h * 1.6) / 600));
        canvas.width = w * dpr;
        canvas.height = h * dpr;
      }
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      function add(drop, x, y, vx, vy) {
        parts.push({ x: x, y: y, vx: vx, vy: vy, age: 0, life: life * (0.75 + Math.random() * 0.35), s: drop.s, size: (12 + Math.random() * 12) * scale, grow: (24 + Math.random() * 26) * scale, drop: drop });
      }

      // A drop: a sinking head with a pair of counter-rotating eddies either
      // side of it (a vortex ring, seen side on), which roll the ink into a
      // mushroom, plus a stem of ink left behind as it sinks.
      function drop(x, y) {
        if (!w || !h) return;
        if (x === undefined) { x = w * (0.18 + Math.random() * 0.64); y = h * (0.08 + Math.random() * 0.18); }
        var D = { x: x, y: y, vy: (0.09 + Math.random() * 0.03) * scale, sep: 6 * scale, gamma: 2.2 * scale, s: colorIndex++ % colors.length, age: 0 };
        drops.push(D);
        var n = Math.round(340 * Math.min(1.2, scale + 0.2));
        for (var i = 0; i < n; i++) {
          var a = Math.random() * Math.PI * 2, r = Math.random() * 14 * scale;
          add(D, x + Math.cos(a) * r, y + Math.sin(a) * r, Math.cos(a) * 0.02, D.vy * (0.6 + Math.random() * 0.6));
        }
        if (parts.length > 3600) parts.splice(0, parts.length - 3600);
      }

      // Velocities are in px per ms.
      function step(dt) {
        t += dt;
        if (autoplay && t >= quietUntil) {
          nextDrop -= dt;
          if (nextDrop <= 0) { drop(); nextDrop = rate * (0.8 + Math.random() * 0.4); }
        }
        for (var k = drops.length - 1; k >= 0; k--) {
          var D = drops[k];
          D.age += dt;
          D.y += D.vy * dt;
          D.vy *= Math.pow(0.9995, dt);
          D.sep += 0.012 * scale * dt * Math.exp(-D.age / 2500);
          D.gamma *= Math.pow(0.9997, dt);
          // A thin stem trails the head early on.
          if (D.age < 2200 && Math.random() < dt / 18) add(D, D.x + (Math.random() - 0.5) * 3, D.y - 6 * scale, 0, D.vy * 0.2);
          if (D.age > 7000) drops.splice(k, 1);
        }
        var flow = 0.035 * scale, ns = 0.005 / scale, tt = t * 0.00005;
        var ease = 1 - Math.pow(0.94, dt / 16), core = 70 * scale * scale;
        for (var i = parts.length - 1; i >= 0; i--) {
          var p = parts[i];
          p.age += dt;
          if (p.age > p.life) { parts.splice(i, 1); continue; }
          var vx = 0, vy = 0.002;
          // Induced velocity from the drop's two eddies.
          var D2 = p.drop;
          if (D2.age < 7000) {
            for (var side = -1; side <= 1; side += 2) {
              var dx = p.x - (D2.x + side * D2.sep), dy = p.y - D2.y;
              var g = (D2.gamma * -side) / (dx * dx + dy * dy + core);
              vx += -dy * g;
              vy += dx * g;
            }
            var hx = p.x - D2.x, hy = p.y - D2.y;
            vy += D2.vy * 0.7 * Math.exp(-(hx * hx + hy * hy) / (900 * scale * scale));
          }
          // Slow currents: the curl of a noise field, so the water swirls.
          var nx = p.x * ns, ny = p.y * ns, e = 0.5;
          vx += (noise(nx + tt, ny + e) - noise(nx + tt, ny - e)) * flow;
          vy -= (noise(nx + e + tt, ny) - noise(nx - e + tt, ny)) * flow;
          p.vx += (vx - p.vx) * ease;
          p.vy += (vy - p.vy) * ease;
          p.x += p.vx * dt;
          p.y += p.vy * dt;
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'multiply';
        for (var i = 0; i < parts.length; i++) {
          var p = parts[i];
          var k = p.age / p.life;
          var a = Math.min(1, p.age / 300) * (1 - k) * (1 - k) * 0.16;
          if (a < 0.005) continue;
          var size = p.size + p.grow * Math.sqrt(k);
          ctx.globalAlpha = a;
          ctx.drawImage(sprites[p.s], p.x - size / 2, p.y - size / 2, size, size);
        }
        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = 'source-over';
      }

      el._iiwDrop = drop;
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        // A still frame: two blooms, already spread.
        drop(w * 0.35, h * 0.2);
        drop(w * 0.65, h * 0.3);
        for (var warm = 0; warm < 160; warm++) step(16);
        draw();
        el._iiwDrop = function () {};
        return;
      }

      el.addEventListener('pointerdown', function (e) {
        var r = el.getBoundingClientRect();
        drop(e.clientX - r.left, e.clientY - r.top);
        quietUntil = t + 4000;
        nextDrop = rate;
      });

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(40, now - last) : 16;
          last = now;
          step(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    window.animacious = window.animacious || {};
    window.animacious.inkDrop = function (el, x, y) { if (el && el._iiwDrop) el._iiwDrop(x, y); };

    function init() {
      document.querySelectorAll('[data-ink-in-water]').forEach(function (el) {
        if (el._iiw) return;
        el._iiw = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-ink-in-water data-autoplay style="background: #f1f5f9">
  <h1>Ink drop</h1>
</section>