A light hero that looks like ink dropped into a tray of water. Under the copy, a small fluid simulation runs on a plain 2D canvas: indigo, vermilion and viridian inks curl, fold into each other and darken where they overlap, the way pigments do on paper. Moving the pointer drags the liquid and leaves a trail of the ink you picked from three swatch buttons. Left alone, three unseen brushes keep stirring. A wash of paper colour behind the serif headline keeps it readable. On phones the grid has fewer cells and the buttons stack.
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
// Each ink is described by how strongly it soaks up red, green and blue light.
// Inks are mixed by adding these numbers, which is how real pigments darken and blend.
const inks = [
{ id: "indigo", name: "Harbour indigo", swatch: "#27247a", absorb: [1.7, 1.45, 0.3] },
{ id: "vermilion", name: "Kiln vermilion", swatch: "#e2412b", absorb: [0.06, 1.25, 1.6] },
{ id: "viridian", name: "Tide viridian", swatch: "#0f8f7c", absorb: [1.65, 0.28, 0.72] },
] as const;
type InkId = (typeof inks)[number]["id"];
/** Paper colour as red, green, blue. */
const PAPER = [247, 243, 234];
/** Rough number of cells in the flow grid. The ink is tracked on a grid twice as fine in each direction. */
const CELLS = 9000;
/** How much ink survives each frame (1 never fades) and how much motion does. */
const INK_FADE = 0.998;
const FLOW_FADE = 0.996;
/** Strength of the small whirls the solver adds back in. 0 turns them off. */
const CURL = 0.3;
/** Edge definition of a blot. 0 is a soft wash, 0.4 is a crisp bloom. */
const EDGE = 0.12;
/** How hard the pointer pushes the liquid. */
const POINTER_FORCE = 0.5;
/** Frames simulated before the first paint, so the page opens on ink that is already moving. */
const WARM_UP_FRAMES = 100;
/** The idle brushes: seconds between puffs, how hard each one pushes and how much ink it carries. */
const PUFF_SECONDS = 2;
const PUFF_FORCE = 0.2;
const PUFF_INK = 0.34;
/** A repeatable random number between 0 and 1 for a whole number. */
const hash = (n: number): number => {
const s = Math.sin(n * 127.1 + 11.7) * 43758.5453;
return s - Math.floor(s);
};
export default function FluidInkCanvasBackgroundThatSwirlsWithThePointer() {
const reduce = !!useReducedMotion();
const rootRef = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [ink, setInk] = useState<InkId>("indigo");
const inkRef = useRef<InkId>("indigo");
inkRef.current = ink;
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
// Flow grid: fw by fh cells plus a one cell border. Ink grid: w by h cells, twice as fine.
let fw = 0;
let fh = 0;
let FW = 0;
let w = 0;
let h = 0;
let W = 0;
let cell = 5;
let u = new Float32Array(0);
let v = new Float32Array(0);
let u2 = new Float32Array(0);
let v2 = new Float32Array(0);
let work = new Float32Array(0);
let pressure = new Float32Array(0);
let dye = [new Float32Array(0), new Float32Array(0), new Float32Array(0)];
let dye2 = [new Float32Array(0), new Float32Array(0), new Float32Array(0)];
let image: ImageData | null = null;
let clock = 0;
let frame = 0;
let onScreen = true;
const pointer = { x: 0, y: 0, px: 0, py: 0, moved: false, fresh: true };
// exp() for every pixel and channel would be slow, so the falloff is read from a table.
const fall = new Float32Array(512);
for (let i = 0; i < 512; i++) fall[i] = Math.exp(-i / 85);
/** Shove the liquid around a point. Position in ink cells, force in flow cells per frame. */
const push = (cx: number, cy: number, fx: number, fy: number, radius: number) => {
const px = cx / 2 + 0.5;
const py = cy / 2 + 0.5;
const r = radius / 2;
const x0 = Math.max(1, Math.floor(px - r * 2.5));
const x1 = Math.min(fw, Math.ceil(px + r * 2.5));
const y0 = Math.max(1, Math.floor(py - r * 2.5));
const y1 = Math.min(fh, Math.ceil(py + r * 2.5));
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {
const g = Math.exp(-((x - px) * (x - px) + (y - py) * (y - py)) / (r * r));
u[x + y * FW] += fx * g;
v[x + y * FW] += fy * g;
}
};
/** Drop ink around a point, in ink cells. */
const drop = (cx: number, cy: number, absorb: readonly number[], amount: number, radius: number) => {
const x0 = Math.max(1, Math.floor(cx - radius * 2.5));
const x1 = Math.min(w, Math.ceil(cx + radius * 2.5));
const y0 = Math.max(1, Math.floor(cy - radius * 2.5));
const y1 = Math.min(h, Math.ceil(cy + radius * 2.5));
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {
const g = Math.exp(-((x - cx) * (x - cx) + (y - cy) * (y - cy)) / (radius * radius)) * amount;
const k = x + y * W;
dye[0][k] += absorb[0] * g;
dye[1][k] += absorb[1] * g;
dye[2][k] += absorb[2] * g;
}
};
let brush = 6;
const step = () => {
clock += 1 / 60;
// Three unseen brushes take turns puffing a jet of ink into the tray. A jet rolls up into a mushroom by itself.
for (let i = 0; i < 3; i++) {
const turn = clock / (PUFF_SECONDS + i * 0.5) + i * 0.37;
const n = Math.floor(turn);
const f = (turn - n) / 0.25;
if (f >= 1) continue;
const ox = w * (0.08 + 0.84 * hash(n * 3 + i * 17));
const oy = h * (0.1 + 0.8 * hash(n * 5 + i * 29 + 1));
const aim = Math.atan2(h / 2 - oy, w / 2 - ox) + (hash(n * 7 + i) - 0.5) * 2;
const k = Math.sin(f * Math.PI);
push(ox, oy, Math.cos(aim) * PUFF_FORCE * k, Math.sin(aim) * PUFF_FORCE * k, brush);
drop(ox, oy, inks[(i + n) % 3].absorb, PUFF_INK * k, brush * 0.8);
}
if (pointer.moved) {
const dx = (pointer.x - pointer.px) / (cell * 2);
const dy = (pointer.y - pointer.py) / (cell * 2);
const speed = Math.min(1, Math.hypot(dx, dy) / 4);
const chosen = inks.find((n) => n.id === inkRef.current) ?? inks[0];
push(pointer.x / cell + 1, pointer.y / cell + 1, dx * POINTER_FORCE, dy * POINTER_FORCE, brush);
drop(pointer.x / cell + 1, pointer.y / cell + 1, chosen.absorb, 0.22 + speed * 0.6, brush * 0.6);
pointer.px = pointer.x;
pointer.py = pointer.y;
pointer.moved = false;
}
// Small whirls get smoothed away by the grid; this finds where the spin is and nudges it back up.
if (CURL > 0) {
for (let y = 1; y <= fh; y++) for (let x = 1; x <= fw; x++) {
const k = x + y * FW;
work[k] = 0.5 * (v[k + 1] - v[k - 1] - u[k + FW] + u[k - FW]);
}
for (let y = 2; y < fh; y++) for (let x = 2; x < fw; x++) {
const k = x + y * FW;
const gx = Math.abs(work[k + 1]) - Math.abs(work[k - 1]);
const gy = Math.abs(work[k + FW]) - Math.abs(work[k - FW]);
const len = Math.hypot(gx, gy) + 1e-5;
u[k] += (CURL * gy * work[k]) / len;
v[k] -= (CURL * gx * work[k]) / len;
}
}
// Carry the flow along itself: each cell looks upstream and takes the blended value it finds there.
for (let y = 1; y <= fh; y++) for (let x = 1; x <= fw; x++) {
const k = x + y * FW;
let sx = x - u[k];
let sy = y - v[k];
if (sx < 0.5) sx = 0.5;
else if (sx > fw + 0.5) sx = fw + 0.5;
if (sy < 0.5) sy = 0.5;
else if (sy > fh + 0.5) sy = fh + 0.5;
const ix = sx | 0;
const iy = sy | 0;
const bx = sx - ix;
const by = sy - iy;
const a = ix + iy * FW;
const w00 = (1 - bx) * (1 - by) * FLOW_FADE;
const w10 = bx * (1 - by) * FLOW_FADE;
const w01 = (1 - bx) * by * FLOW_FADE;
const w11 = bx * by * FLOW_FADE;
u2[k] = w00 * u[a] + w10 * u[a + 1] + w01 * u[a + FW] + w11 * u[a + 1 + FW];
v2[k] = w00 * v[a] + w10 * v[a + 1] + w01 * v[a + FW] + w11 * v[a + 1 + FW];
}
[u, u2] = [u2, u];
[v, v2] = [v2, v];
// Liquid cannot pile up: measure where flow bunches, solve for pressure, and let pressure push it apart.
for (let y = 1; y <= fh; y++) for (let x = 1; x <= fw; x++) {
const k = x + y * FW;
work[k] = -0.5 * (u[k + 1] - u[k - 1] + v[k + FW] - v[k - FW]);
pressure[k] *= 0.7;
}
for (let n = 0; n < 8; n++) {
for (let y = 1; y <= fh; y++) for (let x = 1; x <= fw; x++) {
const k = x + y * FW;
pressure[k] = (work[k] + pressure[k - 1] + pressure[k + 1] + pressure[k - FW] + pressure[k + FW]) * 0.25;
}
}
for (let y = 1; y <= fh; y++) for (let x = 1; x <= fw; x++) {
const k = x + y * FW;
u[k] -= 0.5 * (pressure[k + 1] - pressure[k - 1]);
v[k] -= 0.5 * (pressure[k + FW] - pressure[k - FW]);
}
// Carry the ink on its finer grid, reading the flow from the coarse one.
const [d0, d1, d2] = dye;
const [e0, e1, e2] = dye2;
for (let y = 1; y <= h; y++) for (let x = 1; x <= w; x++) {
const gx = x * 0.5 + 0.25;
const gy = y * 0.5 + 0.25;
const jx = gx | 0;
const jy = gy | 0;
const cx = gx - jx;
const cy = gy - jy;
const q = jx + jy * FW;
const uu = (1 - cy) * (u[q] + cx * (u[q + 1] - u[q])) + cy * (u[q + FW] + cx * (u[q + 1 + FW] - u[q + FW]));
const vv = (1 - cy) * (v[q] + cx * (v[q + 1] - v[q])) + cy * (v[q + FW] + cx * (v[q + 1 + FW] - v[q + FW]));
let sx = x - uu * 2;
let sy = y - vv * 2;
if (sx < 0.5) sx = 0.5;
else if (sx > w + 0.5) sx = w + 0.5;
if (sy < 0.5) sy = 0.5;
else if (sy > h + 0.5) sy = h + 0.5;
const ix = sx | 0;
const iy = sy | 0;
const bx = sx - ix;
const by = sy - iy;
const a = ix + iy * W;
const w00 = (1 - bx) * (1 - by) * INK_FADE;
const w10 = bx * (1 - by) * INK_FADE;
const w01 = (1 - bx) * by * INK_FADE;
const w11 = bx * by * INK_FADE;
const k = x + y * W;
e0[k] = w00 * d0[a] + w10 * d0[a + 1] + w01 * d0[a + W] + w11 * d0[a + 1 + W];
e1[k] = w00 * d1[a] + w10 * d1[a + 1] + w01 * d1[a + W] + w11 * d1[a + 1 + W];
e2[k] = w00 * d2[a] + w10 * d2[a + 1] + w01 * d2[a + W] + w11 * d2[a + 1 + W];
}
[dye, dye2] = [dye2, dye];
};
const paint = () => {
if (!image) return;
const px = image.data;
const [d0, d1, d2] = dye;
let o = 0;
for (let y = 1; y <= h; y++) {
for (let x = 1; x <= w; x++) {
const k = x + y * W;
// Thin haze is pushed down and dense ink kept, which gives the blots a defined edge.
const dr = d0[k];
const dg = d1[k];
const db = d2[k];
const r = ((dr * dr) / (dr + EDGE)) * 85;
const g = ((dg * dg) / (dg + EDGE)) * 85;
const b = ((db * db) / (db + EDGE)) * 85;
px[o] = PAPER[0] * fall[r > 511 ? 511 : r | 0];
px[o + 1] = PAPER[1] * fall[g > 511 ? 511 : g | 0];
px[o + 2] = PAPER[2] * fall[b > 511 ? 511 : b | 0];
px[o + 3] = 255;
o += 4;
}
}
ctx.putImageData(image, 0, 0);
};
const loop = () => {
step();
paint();
frame = requestAnimationFrame(loop);
};
const start = () => {
cancelAnimationFrame(frame);
if (reduce || !onScreen || document.hidden) return;
frame = requestAnimationFrame(loop);
};
const resize = () => {
const cw = root.clientWidth;
const ch = root.clientHeight;
cell = Math.max(2.5, Math.sqrt((cw * ch) / CELLS) / 2);
fw = Math.max(8, Math.round(cw / (cell * 2)));
fh = Math.max(8, Math.round(ch / (cell * 2)));
FW = fw + 2;
w = fw * 2;
h = fh * 2;
W = w + 2;
const flow = () => new Float32Array(FW * (fh + 2));
const fine = () => new Float32Array(W * (h + 2));
u = flow();
v = flow();
u2 = flow();
v2 = flow();
work = flow();
pressure = flow();
dye = [fine(), fine(), fine()];
dye2 = [fine(), fine(), fine()];
// The canvas is only as large as the ink grid. The browser stretches it smoothly, which keeps the edges soft.
canvas.width = w;
canvas.height = h;
image = ctx.createImageData(w, h);
brush = Math.max(4, Math.sqrt(w * h) * 0.045);
clock = 0;
// Start with a few blots already thrown in, each with a shove, so the first frame is mid-swirl.
for (let j = 0; j < 9; j++) {
const ox = w * (0.08 + 0.84 * hash(j * 13 + 2));
const oy = h * (0.1 + 0.8 * hash(j * 31 + 5));
const aim = hash(j * 53 + 9) * Math.PI * 2;
push(ox, oy, Math.cos(aim) * 2.2, Math.sin(aim) * 2.2, brush);
drop(ox, oy, inks[j % 3].absorb, 4.5, brush);
}
for (let i = 0; i < WARM_UP_FRAMES; i++) step();
paint();
};
const move = (e: PointerEvent) => {
const box = root.getBoundingClientRect();
pointer.x = e.clientX - box.left;
pointer.y = e.clientY - box.top;
if (pointer.fresh) {
pointer.px = pointer.x;
pointer.py = pointer.y;
pointer.fresh = false;
}
pointer.moved = true;
};
const leave = () => (pointer.fresh = true);
let lastWidth = 0;
const sizes = new ResizeObserver(() => {
if (Math.abs(root.clientWidth - lastWidth) < 2) return;
lastWidth = root.clientWidth;
resize();
});
sizes.observe(root);
const view = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
start();
});
view.observe(root);
root.addEventListener("pointermove", move);
root.addEventListener("pointerleave", leave);
root.addEventListener("pointercancel", leave);
document.addEventListener("visibilitychange", start);
lastWidth = root.clientWidth;
resize();
start();
return () => {
cancelAnimationFrame(frame);
sizes.disconnect();
view.disconnect();
root.removeEventListener("pointermove", move);
root.removeEventListener("pointerleave", leave);
root.removeEventListener("pointercancel", leave);
document.removeEventListener("visibilitychange", start);
};
}, [reduce]);
const rise = (delay: number) => ({
initial: reduce ? false : ({ opacity: 0, y: 18 } as const),
animate: { opacity: 1, y: 0 },
transition: { duration: 0.8, delay, ease: [0.22, 0.61, 0.36, 1] as const },
});
return (
<section ref={rootRef} className="relative flex min-h-[700px] w-full items-center justify-center overflow-hidden px-5 py-20" style={{ background: `rgb(${PAPER.join(",")})` }}>
<canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" style={{ filter: "blur(2.5px)" }} aria-hidden="true" />
{/* A wash of paper behind the copy keeps dark text readable when ink drifts under it. */}
<div className="pointer-events-none absolute inset-0" style={{ background: `radial-gradient(ellipse 470px 300px at 50% 50%, rgba(${PAPER.join(",")}, 0.84) 0%, rgba(${PAPER.join(",")}, 0.55) 55%, rgba(${PAPER.join(",")}, 0) 100%)` }} aria-hidden="true" />
<div className="relative z-10 mx-auto flex max-w-2xl flex-col items-center text-center text-stone-900">
<motion.p {...rise(0.05)} className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 sm:tracking-[0.28em]">
Marlowe & Tide · Pen inks
</motion.p>
<motion.h1 {...rise(0.15)} className="mt-6 font-serif text-5xl font-medium leading-[1.02] tracking-tight sm:text-7xl">
Ink that moves
<br />
<em className="font-normal">like weather.</em>
</motion.h1>
<motion.p {...rise(0.3)} className="mt-6 max-w-md text-base leading-relaxed text-stone-800 sm:text-lg">
Small batch inks, milled by hand on the Northumberland coast and bottled forty at a time. Stir the water to try a colour.
</motion.p>
<motion.div {...rise(0.42)} className="mt-8 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
<a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-full bg-stone-900 px-7 text-sm font-semibold text-white transition hover:bg-stone-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f7f3ea] sm:w-auto">
Shop the spring set
</a>
<a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-full border border-stone-900/40 px-7 text-sm font-semibold text-stone-900 transition hover:bg-stone-900/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f7f3ea] sm:w-auto">
Order a sample card
</a>
</motion.div>
<motion.div {...rise(0.56)} className="mt-10 flex flex-wrap items-center justify-center gap-2" role="group" aria-label="Ink colour under your pointer">
{inks.map((n) => (
<button
key={n.id}
type="button"
aria-pressed={ink === n.id}
onClick={() => setInk(n.id)}
className={`inline-flex h-10 items-center gap-2 rounded-full border px-4 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f7f3ea] ${ink === n.id ? "border-stone-900 bg-stone-900 text-white" : "border-stone-900/25 bg-[#f7f3ea]/70 text-stone-800 hover:border-stone-900/60"}`}
>
<span className="h-3 w-3 rounded-full ring-1 ring-white/60" style={{ background: n.swatch }} aria-hidden="true" />
{n.name}
</button>
))}
</motion.div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/fluid-ink-canvas-background-that-swirls-with-the-pointer.json
The hero is divided into a coarse grid. Each cell stores a flow direction and three ink amounts. Every frame the flow and the ink are carried along the flow, then a pressure step removes any place where liquid would pile up or thin out. That pressure step is what turns a straight push into curling vortices.
The grid does not store colours. It stores how much red, green and blue each cell absorbs, and the paper colour is dimmed by that amount. Two inks in one cell absorb more together, so mixes get darker and muddier instead of brighter.
No. It is ordinary JavaScript arrays and one `putImageData` call per frame. The canvas is only as large as the grid, around 190 by 105 pixels on a laptop, and the browser stretches it, which also gives the soft edges.
The cost grows with `CELLS`. At the default the work per frame is a few passes over about twenty thousand numbers, which stays within a couple of milliseconds on a mid range phone. Lower `CELLS` or the `8` pressure passes if you need more headroom.
Yes. In `paint`, start from black and add the dye values instead of dimming the paper, then set `PAPER` to a dark colour and switch the text to white.
Related: Particle Flow Field Canvas Background, Gradient Mesh Background with Flowing Blobs and Grain, Flowing Wave Lines Hero Background.
More animated backgrounds or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.