Ink & Echo
— how it works
A meditative sumi-e instrument built entirely with Canvas 2D — no images, no textures, no build step. Everything on the paper, from the grain to the vermilion seal, is procedural. This guide walks through every technique.
01Concept
Sumi-e — Japanese ink-wash painting — is about the irreversibility of one stroke. Ink meets wet rice paper, spreads, feathers, dries. The twist here is the echo: a few beats after you finish a stroke, the paper answers with three fading, drifting replays of your gesture — like a memory dissolving in grey usuzumi wash. The name in the corner, 墨響 (bokkyō), is literally "ink-resonance".
The whole page is one instrument: a full-viewport canvas, a floating toolbar of brush sizes and ink tones, and a single ceremonial act to finish — stamping a carved vermilion seal and saving the sheet.
02Art direction
One dominant mood: warm washi paper under soft north light. Five named values, nothing else on the page:
Type: Shippori Mincho (a Japanese Mincho serif — it also sets the 墨響 glyphs and the seal characters), Zen Kaku Gothic New for quiet UI labels in letter-spaced small caps, and IBM Plex Mono for hints and captions. The paper itself is two stacked CSS radial gradients plus a procedural grain tile (below), multiplied on top of everything.
03The paper
The grain is a 160×160 canvas tile: sparse bright speckles with random low alpha, plus ~26 short strokes as paper fibres. It is converted to a data-URL and used as the background-image of a fixed overlay with mix-blend-mode: multiply — so the same tile textures the live page and the exported PNG.
const img = gx.createImageData(160, 160);
for (let i = 0; i < img.data.length; i += 4) {
const v = 205 + Math.random() * 50; // bright speckle field
img.data[i] = v; img.data[i+1] = v - 4; img.data[i+2] = v - 14;
img.data[i+3] = Math.random() < 0.5 ? 26 : 10; // sparse, faint
}
04The brush
The engine never strokes paths. It stamps pre-rendered dab sprites along the pointer's path — the classic fast-brush technique. Each ink tone gets four 64×64 sprites: a radial gradient dab with small holes bitten out of its rim via destination-out, so the edge reads as bristle fibre rather than a perfect circle. A fifth sprite is a scatter of tiny dots used when the brush runs dry.
Three physical behaviours make it feel like a real fudé:
- Velocity → width. Smoothed pointer speed thins the stroke: fast flicks are lean and dry, slow presses are wide and wet.
- Ink load. Every stroke starts fully charged; distance drains it. Low load lowers alpha until the engine swaps to the dry speckle sprite — the classic kasure streak.
- Coalesced events.
e.getCoalescedEvents()feeds every intermediate pointer sample into the stamper, so fast gestures stay continuous.
function stampSegment(dabs, x0, y0, x1, y1, r0, r1, alpha) {
const dist = Math.hypot(x1 - x0, y1 - y0);
const steps = Math.max(1, Math.ceil(dist / (Math.min(r0, r1) * 0.38 + 0.6)));
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const r = r0 + (r1 - r0) * t;
const dry = alpha < 0.13; // out of ink → kasure speckle
stamp(dabs, x0 + (x1-x0)*t, y0 + (y1-y0)*t, Math.max(0.7, r), alpha, dry);
}
}
05The bleed
Wherever the brush lands, lingers, or lifts, a bloom is spawned: for about a second, every frame stamps a handful of nearly-invisible dabs (alpha ≈ 0.016) in a ring that expands with an out-cubic ease. Because the dabs accumulate frame over frame on the persistent ink layer, the edge darkens gradually and unevenly — which is exactly how ink feathers into wet paper. No fluid solver needed; time + accumulation does the work.
const e = 1 - Math.pow(1 - t, 3); // outCubic ease
const radius = b.r0 + (b.r1 - b.r0) * e;
for (let j = 0; j < 5; j++) { // 5 ghost dabs per frame
const a = Math.random() * Math.PI * 2;
const d = radius * (0.55 + Math.random() * 0.45);
stamp(dabs, b.x + Math.cos(a)*d, b.y + Math.sin(a)*d,
b.r0 * 0.4, 0.016 * (1 - e * 0.7), false);
}
06The echo
Every stroke is recorded as {x, y, r, a, t} points with millisecond timestamps. On pointer-up, three replays are scheduled at ~0.65s, 1.5s and 2.35s. Each one re-runs the exact same stamper with three transformations:
- Drift — all echoes of a stroke share one random direction; each successive echo drifts further along it (26px, 52px, 78px), biased slightly upward like rising smoke.
- Fade — alpha multipliers 0.34 → 0.17 → 0.075, drawn with a paler pigment (black echoes in grey usuzumi, vermilion echoes in a washed coral).
- Spread — radius multipliers 1.16 → 1.32 → 1.48; older memories blur wider.
function scheduleEchoes(pts, toneName) {
const dir = Math.random() * Math.PI * 2;
const ALPHAS = [0.34, 0.17, 0.075];
for (let n = 0; n < 3; n++) {
replays.push({
pts, idx: 1,
dabs: makeEchoDabs(toneName),
start: performance.now() + 650 + n * 850,
dx: Math.cos(dir) * 26 * (n + 1),
dy: Math.sin(dir) * 26 * (n + 1) * 0.6 - 8 * (n + 1),
alphaK: ALPHAS[n],
rK: 1 + 0.16 * (n + 1), // older memories blur wider
});
}
}
The replay ticker walks each recorded point list by timestamp fraction, so an echo plays back with the same rhythm you drew with — hesitations included. The same machinery draws the opening ensō: a parametric circle with sine-wobble radius and a pressure profile (heavy entry, lean middle, sharp taper) is fed into the replay queue on load, then echoes itself.
07The seal & export
The hanko is drawn in code at 2× resolution: a square traced with jittered vertices (stone edges are never straight), an inner keyline, the characters 墨 and 響 set in Shippori Mincho, then ~110 speckles erased with destination-out for stone erosion. Pressing Stamp & Save thumps a DOM ghost of the seal onto the page (a 0.5s overshoot scale animation), bakes it into the ink layer, then composes the export offscreen: paper gradient → grain pattern (multiply) → ink layer → mono caption — and triggers a PNG download.
// stone edges are never straight
const j = () => (Math.random() - 0.5) * s * 0.028;
x.lineTo(px + j(), py + j());
...
x.globalCompositeOperation = 'destination-out';
for (let i = 0; i < 110; i++) { // erosion speckles
x.arc(Math.random()*s, Math.random()*s, 0.6 + Math.random()*2.6, 0, Math.PI*2);
}
08Details that carry the mood
- Motes — up to 38 dust particles drift upward on a separate multiply-blended canvas, with sinusoidal sway and twinkling alpha, so the paper is never dead still.
- Wash — clearing is not a
clearRect. For ~1s the ink is erased with dozens of soft random radial gradients viadestination-out, so the painting dissolves mottled, like water poured over it. - Custom cursor — a hairline ring sized to the brush, shrinking on press; the OS cursor is hidden (pointer-fine devices only).
- Entrance — UI elements rise in with staggered 0.9s expo transitions; the ensō starts drawing itself 450ms after load.
- Reduced motion —
prefers-reduced-motionfreezes motes, skips the seal thump, draws the ensō and echoes instantly, and clears instead of dissolving. - Performance — devicePixelRatio capped at 2, sprites pre-rendered per tone, no gradients created inside the stamping hot path.
09The scroll below the paper
The instrument is only the first sheet. Scrolling reveals a full editorial site — and every visual in it is painted by the same dab-stamping engine, scoped to small canvases (studies.js):
- Gallery of studies — eight pieces (two ensō, bamboo, mountains, orchid, moon, reeds, rain) are generated from parametric stroke scores: each score is a list of
{x,y,r,a,t}point curves built with a seededmulberry32RNG, then animated point-by-point when the sheet scrolls into view. Clicking a sheet reseeds and repaints it — no two visits hang the same show. - Ink-physics demos — three looping micro-canvases isolate nijimi (bleed), kasure (dry brush) and hibiki (echo). Each runs a build → play → dissolve cycle (the dissolve is a per-frame low-alpha
destination-outfill), and an IntersectionObserver pauses any demo that leaves the viewport. - The tone scale — five swatches paint the classical five colours of ink (焦濃重淡清) as live wash strokes: one shared stroke generator, five parameter sets (alpha ceiling, pass count, drain rate). The scorched tone multiplies alpha by
pow(rng, 1.5)per point so it collapses below the dry threshold and breaks into speckle. Cards are orientation-aware: vertical strokes on desktop, horizontal on the single-column mobile ladder. Click to regrind. - Editorial sections — philosophy (sumi, ma, hibiki) and practice exercises in Shippori Mincho, with staggered IntersectionObserver reveals, giant watermark kanji per section, and a scroll-aware sticky nav that underlines the active section.
- Mobile nav — on small screens the anchor links collapse behind a 目次 (contents) chip; opening it drops the section chips in with a 40ms stagger, and any outside tap closes it.
10Replicate it
- Stack two fixed canvases (
inkpersistent,fxcleared per frame) over a warm gradient body; add a multiply-blended grain tile and a vignette div. - Pre-render 4 gradient dab sprites + 1 speckle sprite per ink tone (64×64, holes bitten from the rim).
- On pointer events, stamp sprites along segments; width from smoothed velocity, alpha from a draining ink load; record
{x,y,r,a,t}. - Spawn expanding low-alpha "bloom" rings at stroke start/lingers/end for the bleed.
- On stroke end, schedule 3 timestamp-faithful replays with drift, fade and spread. That's the echo.
- Draw a jittered vermilion square with white glyphs and erosion speckles; bake it in and export the composed layers as PNG.
Total: one HTML file, one stylesheet, two scripts — the instrument (ink.js) and the scroll (studies.js). Go draw something quiet →