back to the paper

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:

Washi#f2ecdd — the paper
Washi deep#e7ddc6 — vignette edge
Sumi#1c1e20 — the ink
Usuzumi#8b8f88 — echo wash
Shu#bf3f2a — seal vermilion

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é:

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:

  1. 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.
  2. 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).
  3. 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

No AI assets. Every visible pixel — paper grain, brush dabs, seal, ensō — is generated by the code on this page. No image prompts were used.

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):

10Replicate it

  1. Stack two fixed canvases (ink persistent, fx cleared per frame) over a warm gradient body; add a multiply-blended grain tile and a vignette div.
  2. Pre-render 4 gradient dab sprites + 1 speckle sprite per ink tone (64×64, holes bitten from the rim).
  3. On pointer events, stamp sprites along segments; width from smoothed velocity, alpha from a draining ink load; record {x,y,r,a,t}.
  4. Spawn expanding low-alpha "bloom" rings at stroke start/lingers/end for the bleed.
  5. On stroke end, schedule 3 timestamp-faithful replays with drift, fade and spread. That's the echo.
  6. 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 →