ink pour

how this site changes its theme

A theme toggle is usually a crossfade - for a moment every colour is one nobody chose. Here the new palette rains in instead, then pours outward from your finger as one connected blot; the type changes colour on the ink's own curve, exactly as fast as the paper it stands on.

implementation

some type, standing on the paper

it should never be ahead of the ink - each palette picks type that contrasts with its own paper, so type on the wrong paper is a washed-out page.

watch it change exactly as fast as the ground it stands on.

style
papers
opening rain22
rain gap60 ms
pour span1050 ms
impact110 ms
soak150 ms
splat size33 px
ragged front0.30
front grain6
press anywhere on the paper - the pour floods from your finger

code

// ink pour - the drop model. Abridged but reproducible:
// every name that is not defined here is explained on the
// line where it is used.
//
// A theme change is one list of timed drops, each
// { x, y, r, at }: centre, full radius in px, and landing
// time in ms after the toggle. Rain opens the list, then a
// pour adds one drop per grid cell; the ordering of those
// cells decides the shape of the pour.

// open with rain - a couple dozen splats, a human beat
// apart. scatter(n) returns n points on a rough grid,
// shuffled: random-looking, but it never clumps.
const drops = [];
let elapsed = 0;
for (const p of scatter(22)) {
elapsed += 60 * (1 + (Math.random() * 2 - 1) * 0.45); // ~60ms ±45%
drops.push({
x: p.x, y: p.y,
r: 13 + Math.random() * 21, // ~13-34px splats
at: elapsed,
});
}

// the pour's board: cells[] holds one point per cell of a
// grid covering the viewport plus ~8% bleed, each wandered
// up to ~16% off its centre. coverR is the one radius that
// still covers a whole cell after the worst wander - every
// pour drop uses it.
// pour as a front - distance from the press point sets the
// firing order; smooth 2d value noise bends it so the ink
// runs ahead in fingers. Per-cell random jitter would put
// salt-and-pepper speckle right at the front line.
const RAGGED = 0.3; // how far the front may bend, as a
const GRAIN = 6; // fraction of the far-corner distance

const far = farthestCorner(pressX, pressY); // px to the corner
const noise = valueNoise2d(seed); // smooth 0..1 field, not random
const cell = (n) => n / (far / GRAIN); // features ~far/6 wide

const front = cells
.map((c) => ({
c,
key:
Math.hypot(c.x - pressX, c.y - pressY) +
(noise(cell(c.x), cell(c.y)) - 0.5) * far * RAGGED,
}))
.sort((a, b) => a.key - b.key);

// every pour drop grows the same way once it lands (see
// scaleAt at the bottom): a spring to LANDING size over
// IMPACT ms, then a soak to full size over SOAK ms.
const IMPACT = 110, SOAK = 150, LANDING = 0.55;

// landing schedule: the sorted cells spread over SPAN
// (~1050ms) counted from pourStart, the moment the rain
// ends. u ** 0.85 lands early cells a touch sooner, so the
// pour opens quickly and settles gently.
front.forEach(({ c }, i) => {
const u = (i + Math.random()) / front.length;
const at = pourStart + SPAN * u ** 0.85;
drops.push({ x: c.x, y: c.y, r: coverR, at });
// when is this cell CERTAINLY inked? once its drop has
// fully grown. The type's colour curve is read off these
// times in use-theme.ts.
pourCells.push({ x: c.x, y: c.y, inkedAt: at + IMPACT + SOAK });
});

// grow, never erase - a drop lands at LANDING via the
// spring (~5% overshoot), then soaks to full size on an
// ease-out cubic. Opaque drops only grow, so the canvas is
// NEVER cleared: each frame strictly adds ink. spring(t)
// is any spring easing: 0..1 in, peaking near 1.05.
function scaleAt(age) {
if (age >= IMPACT + SOAK) return 1;
if (age >= IMPACT) {
const u = (age - IMPACT) / SOAK;
return LANDING + (1 - LANDING) * (1 - (1 - u) ** 3);
}
return LANDING * spring(age / IMPACT);
}

credits

date
august 4, 2026