Variable typographic ASCII
Oct 13, 2026 · p5.js
A manuscript of the site itself: the Wind poem line, the tagline, the availability note, and the build swarm's boot command, cycled into walls of text — after pretext's variable typographic ASCII demo, Neither/Nor's Illustrated Manuscript, and every reel where a cursor lands like a drop of soap in water.
pretext does the typesetting: measured word widths break the lines, measured
per-character advances give every glyph its home position, justified — in a
proportional variable font, no less. Physics does the rest. A slow field of
noise is the resting ink: on the right wall it drives each glyph's weight across
Archivo's wght axis (100–850), so the text carries its own topography.
The pointer is the soap. Move and it sheds ripples sized to its speed; click for a proper drop. Each expanding front shoves glyphs radially — damped springs pull them home with a little overshoot — and, on the variable wall only, bolds and greens them as it passes, so the wave is drawn in ink by the letters it touches. The monospace wall takes the same water but warps by position alone: same disturbance, two inks.
The code
This is the exact file that is running above, baked in at build time — what you read is what you get.
import { useEffect, useRef } from "preact/hooks";
import { measureNaturalWidth, prepareWithSegments } from "@chenglou/pretext";
import type p5 from "p5";
import { bootCommand } from "../../data/genesis.ts";
import { profile } from "../../data/mod.ts";
import { poemLine, poemNote } from "../../data/poem.ts";
/**
* Variable typographic ASCII, the ripple-manuscript cut — after pretext's
* variable typographic ASCII demo (chenglou.me/pretext), Neither/Nor's
* Illustrated Manuscript, and every reel where a cursor lands like a drop
* of soap in water. The site's own words are typeset into two walls of
* text: pretext measures word widths to break lines and per-character
* advances to place every glyph, justified — in a proportional variable
* font. Each glyph then lives at a home position held by a damped spring.
* A slow noise field is the resting ink (weight topography on the right
* wall); the pointer sheds ripples sized to its speed, and a click is a
* proper drop. Each expanding front shoves glyphs radially and, on the
* variable wall only, bolds and greens them as it passes — the wave is
* drawn by the letters it touches. p5.js in instance mode; the code below
* the canvas is this exact file.
*/
const FS = 12; // glyph size, px
const LINE_H = 15; // row pitch, px
const SPRING_K = 0.09; // pull toward home
const SPRING_D = 0.86; // per-frame damping (slight overshoot)
const RIPPLE_SPEED = 240; // wavefront, px/s
const RIPPLE_SIGMA = 26; // wavefront gaussian width, px
const RIPPLE_LIFE = 2.4; // s
const RIPPLE_DECAY = 0.9; // amplitude time constant, s
const MAX_RIPPLES = 14;
const DROP_EVERY = 90; // pointer-spawn rate limit, ms
const HOT = 0.3; // excite level at which glyphs turn accent
/** The manuscript is the site itself. */
const corpus = [
poemLine,
profile.tagline,
profile.availability,
poemNote,
bootCommand,
].join(" · ");
interface Glyph {
ch: string;
hx: number; // home x, px
hy: number; // home y, px
dx: number; // spring offset
dy: number;
vx: number;
vy: number;
ex: number; // ripple excitation, 0..~1
}
interface Ripple {
x: number;
y: number;
born: number; // ms timestamp
amp: number;
}
export default function TypographicAscii() {
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const reduced = globalThis.matchMedia("(prefers-reduced-motion: reduce)")
.matches;
let cleanup = () => {};
let running = true;
(async () => {
const p5 = (await import("p5")).default;
const css = getComputedStyle(document.documentElement);
const paper = css.getPropertyValue("--color-paper").trim() || "#f6f5f1";
const ink = css.getPropertyValue("--color-ink").trim() || "#17150f";
const accent = css.getPropertyValue("--color-accent").trim() || "#0d6b57";
const fontVar = css.getPropertyValue("--font-sans").trim() ||
'"Archivo Variable"';
const fontMono = css.getPropertyValue("--font-mono").trim() ||
'"JetBrains Mono Variable"';
// Variable-font faces load lazily; typeset only once the bytes exist.
await Promise.all([
document.fonts.load(`400 ${FS}px ${fontVar}`),
document.fonts.load(`400 ${FS}px ${fontMono}`),
]);
// pretext, the typesetter: word widths break the lines, per-character
// advances place every glyph inside them.
const widthOf = (s: string, font: string) =>
measureNaturalWidth(prepareWithSegments(s, font));
const fontVarAt = (w: number) => `${w} ${FS}px ${fontVar}`;
const spaceVar = widthOf("i i", fontVarAt(400)) -
widthOf("ii", fontVarAt(400));
const charAdv = new Map<string, number>();
const advanceOf = (ch: string) => {
let a = charAdv.get(ch);
if (a === undefined) {
a = widthOf(ch, fontVarAt(400));
charAdv.set(ch, a);
}
return a;
};
let monoAdvance = FS * 0.6;
for (const ch of new Set(corpus)) {
monoAdvance = Math.max(
monoAdvance,
widthOf(ch, `${FS}px ${fontMono}`),
);
}
const words = corpus.split(" ");
const sketch = (p: p5) => {
let mono: Glyph[] = [];
let variable: Glyph[] = [];
let ripples: Ripple[] = [];
let pointer = { x: -9999, y: -9999, t: 0 };
let lastDrop = 0;
let autoDrop = !reduced; // one hello drop so the mechanic reads without a pointer
let t = 0;
/* --- the typesetter ------------------------------------------- */
const typeset = () => {
mono = [];
variable = [];
ripples = [];
const pad = 10;
const gap = 24; // gutter between the two walls
const halfW = Math.max((p.width - gap) / 2, 72);
const panels = [
{ x0: pad, w: halfW - pad },
{ x0: p.width - halfW + pad, w: halfW - pad },
];
const rows = Math.max(6, Math.floor((p.height - 2 * pad) / LINE_H));
// Right wall: proportional, justified, measured by pretext.
{
const maxW = panels[1].w;
let row = 0;
let line: { w: string; width: number }[] = [];
let lineNat = 0;
const wordWidths = new Map<string, number>();
const widthOfWord = (w: string) => {
let m = wordWidths.get(w);
if (m === undefined) {
m = Math.max(widthOf(w, fontVarAt(400)), 1);
wordWidths.set(w, m);
}
return m;
};
const flush = () => {
if (line.length === 0) return;
// Justify: distribute the slack across the word gaps.
const gapW = line.length > 1
? (maxW - lineNat) / (line.length - 1) + spaceVar
: spaceVar;
let cx = panels[1].x0;
const cy = pad + row * LINE_H + LINE_H / 2;
for (const { w, width } of line) {
let sum = 0;
for (const ch of w) sum += advanceOf(ch);
const scale = sum > 0 ? width / sum : 1;
let ax = 0;
for (const ch of w) {
const a = advanceOf(ch) * scale;
variable.push({
ch,
hx: cx + ax + a / 2,
hy: cy,
dx: 0,
dy: 0,
vx: 0,
vy: 0,
ex: 0,
});
ax += a;
}
cx += width + gapW;
}
line = [];
lineNat = 0;
row++;
};
let wi = 0;
while (row < rows) {
const word = words[wi % words.length];
const width = widthOfWord(word);
if (line.length > 0 && lineNat + spaceVar + width > maxW) {
flush();
} else {
if (line.length > 0) lineNat += spaceVar;
line.push({ w: word, width });
lineNat += width;
wi++;
}
}
flush();
}
// Left wall: same words on the monospace grid, wrapped by count.
{
const cols = Math.max(8, Math.floor(panels[0].w / monoAdvance));
let row = 0, col = 0;
let wi = 0;
while (row < rows) {
const word = words[wi % words.length];
if (col > 0 && col + word.length + 1 > cols) {
row++;
col = 0;
} else {
for (const ch of word) {
if (col >= cols) {
row++;
col = 0;
}
mono.push({
ch,
hx: panels[0].x0 + col * monoAdvance + monoAdvance / 2,
hy: pad + row * LINE_H + LINE_H / 2,
dx: 0,
dy: 0,
vx: 0,
vy: 0,
ex: 0,
});
col++;
}
col++; // the space the split() ate
wi++;
}
}
}
};
/* --- the water -------------------------------------------------- */
const drop = (x: number, y: number, amp: number) => {
if (ripples.length >= MAX_RIPPLES) ripples.shift();
ripples.push({ x, y, born: p.millis(), amp });
};
const physics = () => {
// Springs pull every glyph home; ripples shove them outward.
const live: { r: Ripple; age: number }[] = [];
for (const r of ripples) {
const age = (p.millis() - r.born) / 1000;
if (age < RIPPLE_LIFE) live.push({ r, age });
}
ripples = live.map((l) => l.r);
const walls: Glyph[][] = [mono, variable];
for (const wall of walls) {
for (const g of wall) {
g.vx = (g.vx - SPRING_K * g.dx) * SPRING_D;
g.vy = (g.vy - SPRING_K * g.dy) * SPRING_D;
g.dx += g.vx;
g.dy += g.vy;
g.ex *= 0.88;
for (const { r, age } of live) {
const dx = g.hx - r.x, dy = g.hy - r.y;
const d2 = dx * dx + dy * dy;
const front = RIPPLE_SPEED * age;
const band = 80;
if (d2 < (front - band) ** 2 || d2 > (front + band) ** 2) {
continue;
}
const d = Math.sqrt(d2) || 1;
const env = Math.exp(
-((d - front) ** 2) / (2 * RIPPLE_SIGMA ** 2),
);
if (env < 0.02) continue;
const amp = r.amp * Math.exp(-age / RIPPLE_DECAY) * env;
g.vx += (dx / d) * amp * 0.9;
g.vy += (dy / d) * amp * 0.9;
g.ex = Math.min(g.ex + amp * 0.35, 1.1);
}
}
}
};
const render = () => {
p.background(paper);
const ctx = p.drawingContext;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// Left wall: one font, one weight — the water shows as warp.
ctx.font = `${FS}px ${fontMono}`;
for (const g of mono) {
const amb = (p.noise(g.hx * 0.006 + t * 0.1, g.hy * 0.006) - 0.5) *
2.6;
ctx.fillStyle = g.ex > HOT ? accent : ink;
ctx.fillText(g.ch, g.hx + g.dx + amb, g.hy + g.dy);
}
// Right wall: the resting ink is a slow weight topography; a
// passing front bolds what it touches.
for (const g of variable) {
const amb = (p.noise(g.hx * 0.006 + t * 0.1, g.hy * 0.006) - 0.5) *
2.6;
const topo = p.noise(g.hx * 0.008, g.hy * 0.008, t * 0.22);
const w = Math.min(100 + topo * 750 + g.ex * 300, 850) | 0;
ctx.fillStyle = g.ex > HOT ? accent : ink;
ctx.font = `${w} ${FS}px ${fontVar}`;
ctx.fillText(g.ch, g.hx + g.dx + amb, g.hy + g.dy);
}
p.stroke(`${ink}22`);
p.line(p.width / 2, 12, p.width / 2, p.height - 12);
p.noStroke();
};
p.setup = () => {
const canvas = p.createCanvas(wrap.clientWidth, 480);
canvas.parent(wrap);
p.pixelDensity(Math.min(globalThis.devicePixelRatio ?? 1, 2));
p.frameRate(30);
p.noStroke();
typeset();
if (reduced) {
// A still manuscript: typeset ink, no water.
render();
p.noLoop();
}
};
p.draw = () => {
t += 0.033;
if (autoDrop && p.frameCount > 15) {
autoDrop = false;
drop(p.width * 0.5, p.height * 0.5, 2.4);
}
physics();
render();
};
p.windowResized = () => {
p.resizeCanvas(wrap.clientWidth, 480);
typeset();
if (reduced) render();
};
const onMove = (e: PointerEvent) => {
const rect = wrap.getBoundingClientRect();
const x = Math.min(Math.max(e.clientX - rect.left, 0), p.width);
const y = Math.min(Math.max(e.clientY - rect.top, 0), p.height);
const now = performance.now();
const dist = Math.hypot(x - pointer.x, y - pointer.y);
if (
!reduced && pointer.x > -9000 && dist > 6 &&
now - lastDrop > DROP_EVERY
) {
drop(x, y, Math.min(0.45 + dist * 0.012, 1.5));
lastDrop = now;
}
pointer = { x, y, t: now };
};
const onClick = (e: MouseEvent) => {
if (reduced) return;
const rect = wrap.getBoundingClientRect();
drop(
Math.min(Math.max(e.clientX - rect.left, 0), p.width),
Math.min(Math.max(e.clientY - rect.top, 0), p.height),
4,
);
};
globalThis.addEventListener("pointermove", onMove, { passive: true });
wrap.addEventListener("click", onClick);
const io = new IntersectionObserver(([entry]) => {
if (reduced) return;
if (entry.isIntersecting && !running) {
p.loop();
running = true;
} else if (!entry.isIntersecting && running) {
p.noLoop();
running = false;
}
});
io.observe(wrap);
cleanup = () => {
io.disconnect();
globalThis.removeEventListener("pointermove", onMove);
wrap.removeEventListener("click", onClick);
p.remove();
};
};
new p5(sketch);
})();
return () => cleanup();
}, []);
return (
<div>
<div class="flex items-baseline justify-between pb-2 font-mono text-[0.62rem] tracking-[0.1em] text-muted uppercase">
<span>jetbrains mono · fixed grid</span>
<span>archivo variable · six weights · pretext typeset</span>
</div>
<div ref={wrapRef} class="w-full border border-line bg-paper" />
</div>
);
}