← Hover & cursor

Fluid Ink Cursor

Move the pointer and it stirs coloured ink into the area. A small fluid simulation carries the ink along, so it curls into swirls and eddies before slowly fading away.

Stir the ink

Move your pointer through it

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-fluid-ink-cursor to the section the ink should flow in.

Options

Add any of these attributes to the same element.

data-colorComma-separated ink colours it drifts through. Default purple, pink and sky blue.
data-sizeHow wide a stroke of ink is, in px. Default 36.
data-fadeHow long the ink lingers, from 0 to 1. Default 0.6.
data-swirlHow strongly the ink curls into eddies. Default 1; 0 for a smooth flow.
data-autoplayA pretend pointer stirs the ink while a real one isn’t in the section. Handy for demos and touch screens.

The ink sits over the section without catching clicks, and looks best on a dark background. The simulation runs on a coarse grid that’s smoothed out when drawn, so it stays light even on big sections. On touch screens, drag a finger through it. With reduced motion turned on, there’s no ink.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Fluid Ink Cursor: moving the pointer stirs coloured ink into an area. A
    // small fluid simulation carries the ink along, so it curls into swirls
    // and eddies before slowly fading away.
    // Usage: <section data-fluid-ink-cursor>…</section>
    // Optional: data-color (comma-separated ink colours it cycles through,
    // default purple, pink and sky blue), data-size (how wide a stroke of ink
    // is, px, default 36), data-fade (how long ink lingers, 0 to 1, default
    // 0.6), data-swirl (how strongly the ink curls, default 1),
    // data-autoplay (a pretend pointer stirs the ink while a real one isn't in
    // the area; handy for demos and touch screens).
    // The ink sits over the area without catching clicks.
    var DEFAULTS = { color: '#a855f7,#ec4899,#38bdf8', size: 36, fade: 0.6, swirl: 1 };

    var css =
      '.fic{position:relative;overflow:hidden}' +
      '.fic-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);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    // Turn any CSS colour into [r, g, b] (0 to 1) by letting the browser parse it.
    var probe = document.createElement('canvas').getContext('2d');
    function rgb(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c;
      var s = probe.fillStyle;
      if (s.charAt(0) === '#') return [parseInt(s.substr(1, 2), 16) / 255, parseInt(s.substr(3, 2), 16) / 255, parseInt(s.substr(5, 2), 16) / 255];
      var m = s.match(/[\d.]+/g) || [0, 0, 0];
      return [m[0] / 255, m[1] / 255, m[2] / 255];
    }

    function setup(el) {
      var d = el.dataset;
      var colours = (d.color || DEFAULTS.color).split(',').map(function (c) { return rgb(c.trim()); });
      var size = num(d.size, DEFAULTS.size);
      var fade = Math.max(0, Math.min(1, num(d.fade, DEFAULTS.fade)));
      var swirl = Math.max(0, num(d.swirl, DEFAULTS.swirl));
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var keep = 0.97 + fade * 0.028; // how much ink survives each frame
      el.classList.add('fic');

      var canvas = document.createElement('canvas');
      canvas.className = 'fic-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      // The simulation runs on a coarse grid, drawn into a small canvas and
      // scaled up smoothly: the blur this gives is what makes it look like ink.
      var small = document.createElement('canvas');
      var sctx = small.getContext('2d');

      var W = 0, H = 0, S = 0, cell = 1, img;
      var u, v, u0, v0, r, g, b, r0, g0, b0, p, div, curl;
      var w = 0, h = 0;

      function grid() {
        w = el.clientWidth; h = el.clientHeight;
        var dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.max(1, Math.round(w * dpr));
        canvas.height = Math.max(1, Math.round(h * dpr));
        // About 110 cells across the long side (fewer on small areas), so it stays cheap.
        cell = Math.max(4, Math.max(w, h) / 110) || 1;
        var nw = Math.max(8, Math.round(w / cell)), nh = Math.max(8, Math.round(h / cell));
        if (nw === W && nh === H) return;
        W = nw; H = nh; S = W + 2;
        var n = S * (H + 2);
        u = new Float32Array(n); v = new Float32Array(n); u0 = new Float32Array(n); v0 = new Float32Array(n);
        r = new Float32Array(n); g = new Float32Array(n); b = new Float32Array(n);
        r0 = new Float32Array(n); g0 = new Float32Array(n); b0 = new Float32Array(n);
        p = new Float32Array(n); div = new Float32Array(n); curl = new Float32Array(n);
        small.width = W; small.height = H;
        img = sctx.createImageData(W, H);
      }

      // ---- Stable fluids (Jos Stam), on a W x H grid with a one-cell border ----
      function bound(k, x) {
        var i, j;
        for (j = 1; j <= H; j++) {
          x[j * S] = k === 1 ? -x[1 + j * S] : x[1 + j * S];
          x[W + 1 + j * S] = k === 1 ? -x[W + j * S] : x[W + j * S];
        }
        for (i = 1; i <= W; i++) {
          x[i] = k === 2 ? -x[i + S] : x[i + S];
          x[i + (H + 1) * S] = k === 2 ? -x[i + H * S] : x[i + H * S];
        }
      }
      function advect(k, dst, src, du, dv) {
        for (var j = 1; j <= H; j++) {
          for (var i = 1; i <= W; i++) {
            var id = i + j * S;
            var x = i - du[id], y = j - dv[id];
            if (x < 0.5) x = 0.5; else if (x > W + 0.5) x = W + 0.5;
            if (y < 0.5) y = 0.5; else if (y > H + 0.5) y = H + 0.5;
            var i0 = x | 0, j0 = y | 0, s1 = x - i0, t1 = y - j0, s0 = 1 - s1, t0 = 1 - t1;
            var a = i0 + j0 * S;
            dst[id] = s0 * (t0 * src[a] + t1 * src[a + S]) + s1 * (t0 * src[a + 1] + t1 * src[a + 1 + S]);
          }
        }
        bound(k, dst);
      }
      function project() {
        var i, j, id;
        for (j = 1; j <= H; j++) {
          for (i = 1; i <= W; i++) {
            id = i + j * S;
            div[id] = -0.5 * (u[id + 1] - u[id - 1] + v[id + S] - v[id - S]);
            p[id] = 0;
          }
        }
        bound(0, div); bound(0, p);
        for (var k = 0; k < 16; k++) {
          for (j = 1; j <= H; j++) {
            for (i = 1; i <= W; i++) {
              id = i + j * S;
              p[id] = (div[id] + p[id - 1] + p[id + 1] + p[id - S] + p[id + S]) / 4;
            }
          }
          bound(0, p);
        }
        for (j = 1; j <= H; j++) {
          for (i = 1; i <= W; i++) {
            id = i + j * S;
            u[id] -= 0.5 * (p[id + 1] - p[id - 1]);
            v[id] -= 0.5 * (p[id + S] - p[id - S]);
          }
        }
        bound(1, u); bound(2, v);
      }
      // Vorticity confinement: nudges the flow round its own eddies, which is
      // what keeps the swirls curling instead of smearing out.
      function vorticity() {
        var i, j, id;
        for (j = 1; j <= H; j++) {
          for (i = 1; i <= W; i++) {
            id = i + j * S;
            curl[id] = 0.5 * (v[id + 1] - v[id - 1] - u[id + S] + u[id - S]);
          }
        }
        for (j = 2; j < H; j++) {
          for (i = 2; i < W; i++) {
            id = i + j * S;
            var nx = 0.5 * (Math.abs(curl[id + 1]) - Math.abs(curl[id - 1]));
            var ny = 0.5 * (Math.abs(curl[id + S]) - Math.abs(curl[id - S]));
            var len = Math.sqrt(nx * nx + ny * ny) + 1e-5;
            var c = curl[id] * swirl * 0.35;
            u[id] += (ny / len) * c;
            v[id] -= (nx / len) * c;
          }
        }
      }
      function step() {
        if (swirl) vorticity();
        var t;
        t = u0; u0 = u; u = t; t = v0; v0 = v; v = t;
        advect(1, u, u0, u0, v0); advect(2, v, v0, u0, v0);
        project();
        for (var i = 0; i < u.length; i++) { u[i] *= 0.992; v[i] *= 0.992; }
        t = r0; r0 = r; r = t; t = g0; g0 = g; g = t; t = b0; b0 = b; b = t;
        advect(0, r, r0, u, v); advect(0, g, g0, u, v); advect(0, b, b0, u, v);
        for (var k = 0; k < r.length; k++) { r[k] *= keep; g[k] *= keep; b[k] *= keep; }
      }

      // Pour ink and push the flow at (x, y) in px, moving by (dx, dy) px.
      var hue = 0;
      function splat(x, y, dx, dy) {
        var gx = x / cell + 1, gy = y / cell + 1, rad = Math.max(1.5, size / cell / 2);
        var mx = dx / cell, my = dy / cell;
        var sp = Math.sqrt(mx * mx + my * my);
        if (sp > 6) { mx *= 6 / sp; my *= 6 / sp; }
        hue = (hue + 0.004) % colours.length;
        var a = colours[Math.floor(hue)], c2 = colours[(Math.floor(hue) + 1) % colours.length], f = hue % 1;
        var cr = a[0] + (c2[0] - a[0]) * f, cg = a[1] + (c2[1] - a[1]) * f, cb = a[2] + (c2[2] - a[2]) * f;
        var amt = Math.min(1, 0.35 + sp * 0.15);
        var i0 = Math.max(1, Math.floor(gx - rad * 2)), i1 = Math.min(W, Math.ceil(gx + rad * 2));
        var j0 = Math.max(1, Math.floor(gy - rad * 2)), j1 = Math.min(H, Math.ceil(gy + rad * 2));
        for (var j = j0; j <= j1; j++) {
          for (var i = i0; i <= i1; i++) {
            var ddx = i - gx, ddy = j - gy;
            var wgt = Math.exp(-(ddx * ddx + ddy * ddy) / (rad * rad));
            if (wgt < 0.01) continue;
            var id = i + j * S;
            u[id] += mx * wgt * 0.9; v[id] += my * wgt * 0.9;
            r[id] += cr * wgt * amt; g[id] += cg * wgt * amt; b[id] += cb * wgt * amt;
          }
        }
      }

      function draw() {
        var data = img.data, o = 0, most = 0;
        for (var j = 1; j <= H; j++) {
          for (var i = 1; i <= W; i++) {
            var id = i + j * S;
            var R = r[id], G = g[id], B = b[id];
            var m = R > G ? (R > B ? R : B) : (G > B ? G : B);
            if (m > most) most = m;
            var al = m > 1 ? 1 : m;
            var sc = m > 0.001 ? 255 / m : 0;
            data[o] = R * sc; data[o + 1] = G * sc; data[o + 2] = B * sc;
            data[o + 3] = 255 * Math.pow(al, 0.8);
            o += 4;
          }
        }
        sctx.putImageData(img, 0, 0);
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.imageSmoothingEnabled = true;
        ctx.imageSmoothingQuality = 'high';
        // A light blur hides the grid (browsers without canvas filters skip it).
        ctx.filter = 'blur(' + (cell * canvas.width / Math.max(1, w) * 0.5).toFixed(1) + 'px)';
        ctx.drawImage(small, 0, 0, canvas.width, canvas.height);
        ctx.filter = 'none';
        return most;
      }

      // ---- Pointer, touch and the pretend pointer ----
      var pt = { x: 0, y: 0, has: false }, hover = false, hold = 0, visible = false, raf = 0, t = Math.random() * 6, last = 0;
      function aim(cx, cy) {
        var bx = el.getBoundingClientRect();
        var x = cx - bx.left, y = cy - bx.top;
        if (pt.has) splatLine(pt.x, pt.y, x, y);
        pt.x = x; pt.y = y; pt.has = true;
        last = Date.now();
        wake();
      }
      // Long moves are split up so the ink stays a continuous stroke.
      function splatLine(x0, y0, x1, y1) {
        var dx = x1 - x0, dy = y1 - y0, dist = Math.sqrt(dx * dx + dy * dy);
        var n = Math.min(12, Math.max(1, Math.ceil(dist / (size * 0.4))));
        for (var k = 1; k <= n; k++) splat(x0 + dx * k / n, y0 + dy * k / n, dx / n * 1.6, dy / n * 1.6);
      }

      function frame() {
        raf = 0;
        if (!visible) return;
        var fake = auto && !hover && Date.now() > hold;
        if (fake) {
          // Lazy figure-of-eight loops with the odd faster flick.
          t += 0.016 + 0.035 * Math.pow(Math.max(0, Math.sin(t * 0.6)), 4);
          var x = w * (0.5 + 0.34 * Math.sin(t * 1.1)), y = h * (0.5 + 0.28 * Math.sin(t * 1.9 + 0.6));
          if (pt.has) splatLine(pt.x, pt.y, x, y);
          pt.x = x; pt.y = y; pt.has = true;
          last = Date.now();
        }
        step();
        var most = draw();
        if (fake || Date.now() - last < 600 || most > 0.004) raf = requestAnimationFrame(frame);
      }
      function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }

      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        hover = true; pt.has = false;
        aim(e.clientX, e.clientY);
      });
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') hold = Date.now() + 3000;
        else hover = true;
        aim(e.clientX, e.clientY);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType !== 'touch') hover = false;
      });
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 3000; pt.has = false;
        aim(e.clientX, e.clientY);
      });

      grid();
      new ResizeObserver(function () { grid(); wake(); }).observe(el);
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);
    }

    function init() {
      if (reduced()) return;
      document.querySelectorAll('[data-fluid-ink-cursor]').forEach(function (el) {
        if (el._fic) return;
        el._fic = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section class="hero" data-fluid-ink-cursor>…</section>

<!-- one colour, lingering longer: -->
<section data-fluid-ink-cursor data-color="#38bdf8" data-fade="0.9">…</section>