"use client";
import * as React from "react";
export type InkTrailPalette = "accent" | "ink" | "tide" | "spectrum";
export interface InkTrailProps extends React.ComponentPropsWithoutRef<"div"> {
/** How quickly ink bleeds into its surroundings, 0–1. */
diffusion?: number;
/** How quickly ink clears, 0–1. 0 keeps ink for a long time. */
fade?: number;
/** Brush diameter, in px. */
brushSize?: number;
/** Colours the brush cycles through as you paint. `accent` uses accent → secondary; `ink` follows the theme. */
palette?: InkTrailPalette;
/** First colour of the accent palette (any CSS colour). */
accent?: string;
/** Second colour of the accent palette (any CSS colour). */
secondary?: string;
/** Let occasional drops fall on their own while nobody is painting. */
autoDrip?: boolean;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
/** On light themes, very pale colours are deepened (keeping their hue) so they still read as ink. */
function legible(color: RGB, ink: RGB): RGB {
if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;
return color.map((v) => Math.round(v * 0.68)) as RGB;
}
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Simulation ------------------------------- */
const STEP_MS = 1000 / 60;
const PALETTES: Record<Exclude<InkTrailPalette, "accent" | "ink">, string[]> = {
tide: ["#1f6feb", "#1f9e89", "#5fb8e8", "#2b4c7e"],
spectrum: ["#ff7448", "#f0b429", "#1f9e89", "#3d8bea", "#e87ba4"],
};
/** Small deterministic PRNG, so the opening composition is the same on every visit. */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* The ink lives on a coarse grid (about 15k cells): density plus colour-weighted density per
* channel, and a velocity field. Each fixed 60Hz step advects the ink along the velocity
* (semi-Lagrangian), blurs it a little (diffusion) and fades it. The grid is written into a
* small ImageData and upscaled with smoothing, which is what makes the edges bleed softly.
*/
interface Field {
w: number;
h: number;
/** Density and colour-weighted density (r, g, b), plus scratch buffers for each step. */
d: Float32Array;
r: Float32Array;
g: Float32Array;
b: Float32Array;
vx: Float32Array;
vy: Float32Array;
t0: Float32Array;
t1: Float32Array;
t2: Float32Array;
t3: Float32Array;
}
function createField(w: number, h: number): Field {
const n = w * h;
const f = () => new Float32Array(n);
return { w, h, d: f(), r: f(), g: f(), b: f(), vx: f(), vy: f(), t0: f(), t1: f(), t2: f(), t3: f() };
}
/**
* Ink that bleeds and drifts like ink dropped in water. Pointer strokes deposit colour and push
* the water along with them; the ink then diffuses, swirls on a slow ambient current and fades.
* Colours cycle through a palette as you paint. Touch paints while the finger is down, with
* passive listeners, so scrolling is never blocked.
*/
export function InkTrail({
diffusion = 0.5,
fade = 0.4,
brushSize = 44,
palette = "accent",
accent = "#FF7448",
secondary = "#B6F3D1",
autoDrip = true,
className = "",
children,
...props
}: InkTrailProps) {
const reduceMotion = usePrefersReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const small = document.createElement("canvas");
const sctx = small.getContext("2d");
if (!canvas || !ctx || !sctx) return;
const blur = 0.004 + Math.min(1, Math.max(0, diffusion)) * 0.07; // explicit diffusion coefficient (≤ 0.25 is stable)
const keep = 1 - (0.0015 + Math.min(1, Math.max(0, fade)) * 0.012); // density kept per step
const brush = Math.max(6, brushSize) / 2;
let width = 0;
let height = 0;
let dpr = 1;
let cell = 6;
let field: Field | null = null;
let image: ImageData | null = null;
let colors: RGB[] = [];
let hue = 0; // position along the palette
let raf = 0;
let last = 0;
let pending = 0;
let active = false;
let inView = false;
let clock = 0; // simulation time in steps
let idleSince = 0;
let nextDrip = 0;
const random = mulberry32(1337);
const ptr = { x: 0, y: 0, px: 0, py: 0, t: 0, down: false, has: false };
const readTheme = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
const list =
palette === "ink"
? [ink]
: palette === "accent"
? [resolveColor(accent, [255, 116, 72]), resolveColor(secondary, [182, 243, 209])]
: PALETTES[palette].map((c) => resolveColor(c, [255, 116, 72]));
colors = list.map((c) => legible(c, ink));
};
/** Current brush colour (0–1 floats), blended smoothly between palette entries. */
const brushColor = (): RGB => {
const n = colors.length;
if (n === 1) return colors[0].map((v) => v / 255) as RGB;
const i = Math.floor(hue) % n;
const j = (i + 1) % n;
const t = hue - Math.floor(hue);
const s = t * t * (3 - 2 * t);
return colors[i].map((v, k) => (v + (colors[j][k] - v) * s) / 255) as RGB;
};
/** Deposits ink (and momentum) in a soft disc around (x, y), given in px. */
const stamp = (x: number, y: number, radius: number, amount: number, vx: number, vy: number) => {
const f = field;
if (!f) return;
const [cr, cg, cb] = brushColor();
const gx = x / cell;
const gy = y / cell;
const rr = Math.max(1, radius / cell);
const x0 = Math.max(0, Math.floor(gx - rr));
const x1 = Math.min(f.w - 1, Math.ceil(gx + rr));
const y0 = Math.max(0, Math.floor(gy - rr));
const y1 = Math.min(f.h - 1, Math.ceil(gy + rr));
for (let j = y0; j <= y1; j++) {
for (let i = x0; i <= x1; i++) {
const dx = i - gx;
const dy = j - gy;
const q = 1 - (dx * dx + dy * dy) / (rr * rr);
if (q <= 0) continue;
const w = q * q;
const k = j * f.w + i;
const a = w * amount;
f.d[k] += a;
f.r[k] += a * cr;
f.g[k] += a * cg;
f.b[k] += a * cb;
f.vx[k] += (vx - f.vx[k]) * w * 0.5;
f.vy[k] += (vy - f.vy[k]) * w * 0.5;
}
}
active = true;
};
/** A drop: a dense blob whose water pushes outward, so it blooms. */
const drop = (x: number, y: number, radius: number) => {
const f = field;
if (!f) return;
stamp(x, y, radius, 0.9, 0, 0);
const gx = x / cell;
const gy = y / cell;
const rr = (radius * 1.6) / cell;
for (let j = Math.max(0, Math.floor(gy - rr)); j <= Math.min(f.h - 1, Math.ceil(gy + rr)); j++) {
for (let i = Math.max(0, Math.floor(gx - rr)); i <= Math.min(f.w - 1, Math.ceil(gx + rr)); i++) {
const dx = i - gx;
const dy = j - gy;
const d = Math.hypot(dx, dy);
if (d < 0.5 || d > rr) continue;
const push = (1 - d / rr) * 0.9;
f.vx[j * f.w + i] += (dx / d) * push;
f.vy[j * f.w + i] += (dy / d) * push;
}
}
hue += 0.37;
};
/** Bilinear sample of `src` at grid coordinates (x, y), clamped to the edges. */
const sample = (src: Float32Array, w: number, h: number, x: number, y: number) => {
x = Math.min(w - 1.001, Math.max(0, x));
y = Math.min(h - 1.001, Math.max(0, y));
const i = x | 0;
const j = y | 0;
const fx = x - i;
const fy = y - j;
const k = j * w + i;
const a = src[k] + (src[k + 1] - src[k]) * fx;
const b = src[k + w] + (src[k + w + 1] - src[k + w]) * fx;
return a + (b - a) * fy;
};
/** One fixed simulation step. Returns true while visible ink remains. */
const step = () => {
const f = field;
if (!f) return false;
const { w, h } = f;
clock++;
// 1. Velocity: decay plus a slow ambient current (a drifting sine field, ~0.08 cells per step at rest).
// Two octaves per axis give the drifting ink its tendrils.
const tt = clock * 0.004;
for (let j = 0; j < h; j++) {
const ax = (Math.sin(j * 0.13 + tt) + Math.sin(j * 0.37 - tt * 1.7) * 0.6) * 0.004;
for (let i = 0; i < w; i++) {
const k = j * w + i;
const ay = (Math.cos(i * 0.11 - tt * 1.3) + Math.cos(i * 0.31 + tt * 1.1) * 0.6) * 0.004;
f.vx[k] = f.vx[k] * 0.95 + ax;
f.vy[k] = f.vy[k] * 0.95 + ay;
}
}
// 2. Advect density and colour backwards along the velocity.
const { d, r, g, b, vx, vy, t0, t1, t2, t3 } = f;
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
const k = j * w + i;
const sx = i - vx[k];
const sy = j - vy[k];
t0[k] = sample(d, w, h, sx, sy);
t1[k] = sample(r, w, h, sx, sy);
t2[k] = sample(g, w, h, sx, sy);
t3[k] = sample(b, w, h, sx, sy);
}
}
// 3. Diffuse (one explicit 5-point pass) and fade, writing back into the main buffers.
let any = false;
const bufs: [Float32Array, Float32Array][] = [
[t0, d],
[t1, r],
[t2, g],
[t3, b],
];
for (const [src, dst] of bufs) {
for (let j = 0; j < h; j++) {
const up = j > 0 ? -w : 0;
const down = j < h - 1 ? w : 0;
for (let i = 0; i < w; i++) {
const k = j * w + i;
const c = src[k];
const left = i > 0 ? src[k - 1] : c;
const right = i < w - 1 ? src[k + 1] : c;
const lap = left + right + src[k + up] + src[k + down] - 4 * c;
const v = (c + blur * lap) * keep;
dst[k] = v < 1e-4 ? 0 : v;
}
}
}
for (let k = 0; k < d.length; k++) {
if (d[k] > 0.004) {
any = true;
break;
}
}
return any;
};
/** Writes the field into the small canvas and upscales it (with smoothing) onto the main one. */
const render = () => {
const f = field;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!f || !image) return;
const px = image.data;
for (let k = 0; k < f.d.length; k++) {
const dens = f.d[k];
const o = k * 4;
if (dens < 0.002) {
px[o + 3] = 0;
continue;
}
const inv = 255 / dens;
px[o] = f.r[k] * inv;
px[o + 1] = f.g[k] * inv;
px[o + 2] = f.b[k] * inv;
// Steep tone curve: thin ink vanishes and body ink reads solid, so edges stay crisp
// while the inside keeps its density variation.
const a = (1 - Math.exp(-dens * 5) - 0.12) / 0.62;
px[o + 3] = a <= 0 ? 0 : Math.min(1, a) * 235;
}
sctx.putImageData(image, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(small, 0, 0, f.w * cell * dpr, f.h * cell * dpr);
};
/** The opening composition: a few seeded drops and one stroke, so the field is never blank. */
const seed = () => {
for (let n = 0; n < 4; n++) {
drop(width * (0.18 + random() * 0.64), height * (0.22 + random() * 0.56), brush * (1 + random() * 0.8));
}
let x = width * 0.12;
let y = height * 0.7;
for (let s = 0; s < 40; s++) {
const nx = x + width * 0.019;
const ny = height * (0.62 - Math.sin(s / 7) * 0.22);
stamp(nx, ny, brush * 0.8, 0.32, (nx - x) / cell / 2, (ny - y) / cell / 2);
hue += 0.02;
x = nx;
y = ny;
}
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : STEP_MS);
last = now;
// Autonomous drops while nobody is painting.
if (autoDrip && now - idleSince > 2400 && now > nextDrip) {
drop(width * (0.12 + random() * 0.76), height * (0.15 + random() * 0.7), brush * (0.8 + random() * 0.9));
nextDrip = now + 1100 + random() * 900;
}
// Fixed 60Hz steps; a short (120/144Hz) frame may run none and keeps the previous verdict.
pending = Math.min(pending + dt, STEP_MS * 3);
if (pending >= STEP_MS) {
let still = false;
while (pending >= STEP_MS) {
still = step() || still;
pending -= STEP_MS;
}
active = still;
}
render();
if (active || autoDrip) schedule();
else last = 0;
};
const schedule = () => {
if (!raf && inView && !document.hidden && !reduceMotion) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
// Reduced motion: no loop. Strokes still leave ink, settled with a few instant steps, and redrawn once.
let stillRaf = 0;
const settleNow = (steps: number) => {
for (let s = 0; s < steps; s++) step();
if (!stillRaf) {
stillRaf = requestAnimationFrame(() => {
stillRaf = 0;
render();
});
}
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && field) return;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
if (width < 1 || height < 1) return;
// ~15k cells regardless of size, never finer than 4px or coarser than 12px.
cell = Math.min(12, Math.max(4, Math.sqrt((width * height) / 15000)));
const fresh = !field;
field = createField(Math.ceil(width / cell) + 1, Math.ceil(height / cell) + 1);
small.width = field.w;
small.height = field.h;
image = sctx.createImageData(field.w, field.h);
if (fresh || reduceMotion) {
seed();
if (reduceMotion) for (let s = 0; s < 70; s++) step();
}
render();
schedule();
};
/* --------------------------------- Input -------------------------------- */
const toLocal = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
return { x, y, inside: x >= 0 && y >= 0 && x <= rect.width && y <= rect.height };
};
/** Stamps along the segment from the previous pointer position, so fast strokes stay continuous. */
const paintTo = (x: number, y: number, now: number) => {
if (!ptr.has) {
ptr.px = x;
ptr.py = y;
ptr.t = now;
ptr.has = true;
}
const dx = x - ptr.px;
const dy = y - ptr.py;
const dist = Math.hypot(dx, dy);
const dt = Math.max(8, now - ptr.t);
// Pointer velocity in grid cells per step, so strokes push the water along.
const scale = STEP_MS / dt / cell;
const vx = Math.max(-3, Math.min(3, dx * scale));
const vy = Math.max(-3, Math.min(3, dy * scale));
const spacing = Math.max(2, brush * 0.35);
const n = Math.max(1, Math.ceil(dist / spacing));
for (let s = 1; s <= n; s++) {
const t = s / n;
stamp(ptr.px + dx * t, ptr.py + dy * t, brush, 0.3, vx, vy);
}
hue += dist / 420;
ptr.px = x;
ptr.py = y;
ptr.t = now;
idleSince = now;
if (reduceMotion) settleNow(2);
else schedule();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && !ptr.down) return;
const p = toLocal(e);
if (!p.inside) {
ptr.has = false;
return;
}
paintTo(p.x, p.y, e.timeStamp || performance.now());
};
const onPointerDown = (e: PointerEvent) => {
const p = toLocal(e);
if (!p.inside) return;
ptr.down = true;
ptr.has = false;
paintTo(p.x, p.y, e.timeStamp || performance.now());
};
const onPointerUp = () => {
ptr.down = false;
ptr.has = false;
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const onThemeChange = () => {
readTheme();
render();
};
const passive = { passive: true } as const;
const listen = (on: boolean) => {
if (on) {
window.addEventListener("pointermove", onPointerMove, passive);
window.addEventListener("pointerdown", onPointerDown, passive);
window.addEventListener("pointerup", onPointerUp, passive);
window.addEventListener("pointercancel", onPointerUp, passive);
} else {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
}
};
// Listening on the window (only while visible) keeps content layered above the canvas interactive.
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
listen(true);
idleSince = performance.now() - 1800;
schedule();
} else {
listen(false);
ptr.has = false;
stop();
}
});
readTheme();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
cancelAnimationFrame(stillRaf);
ro.disconnect();
io.disconnect();
unobserveTheme();
listen(false);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [diffusion, fade, brushSize, palette, accent, secondary, autoDrip, reduceMotion]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 mix-blend-multiply dark:text-zinc-100 dark:mix-blend-screen"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}