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 itHow to use it
- Paste the snippet just before
</head>. - Add
data-fluid-ink-cursorto the section the ink should flow in.
Options
Add any of these attributes to the same element.
data-color | Comma-separated ink colours it drifts through. Default purple, pink and sky blue. |
data-size | How wide a stroke of ink is, in px. Default 36. |
data-fade | How long the ink lingers, from 0 to 1. Default 0.6. |
data-swirl | How strongly the ink curls into eddies. Default 1; 0 for a smooth flow. |
data-autoplay | A 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.
<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><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>