Canvas target
Move the same walker to a different canvas and the whole scene changes. The text is still Pretext's — same segments, same line breaks, same measured widths — but now it lives on a <canvas>, beside particles that drift through and around it. Because layout happens in JS and the draw call is just ctx.fillText, the loop holds 60 fps while the text stays crisp and correctly broken at every width.
Slide the width to reflow the paragraph. Slide density to feed more or fewer particles into the scene. The numbers in the corner are real — they update every frame.
Mechanism
We call prepareWithSegments(text, font) once at boot. When the wrap width changes — and only then — we call layoutWithLines(prepared, width, lineHeightPx) and cache the returned lines array. Each line is { text, width, start, end }. Inside the RAF loop we clear the canvas, draw a drift of particles, then draw text by calling ctx.fillText(line.text, x, baselineY) for each cached line.
Canvas text is drawn from the baseline, not the top. We derive the first line's baseline from the line height: baseline = lineHeight * ascentRatio, with ascentRatio ≈ 0.78 for most proportional fonts at this size. Subsequent lines add lineHeight each. We mirror the container's CSS font onto the 2D context via ctx.font so the drawn glyphs match what Pretext measured.
Because the hot path never touches the DOM — no getBoundingClientRect, no textMetrics probing per segment — the frame budget is spent on particles, compositing, and paint. Text layout is effectively free once prepare has run.
Application
Anywhere text has to share a surface with live motion:
- In-game UI — nameplates, dialog, captions that wrap correctly inside a running canvas scene.
- Data-viz with narration — a headline or legend that lives inside the plot, not above it.
- Kinetic typography — text as a first-class scene element, not a DOM overlay fighting z-index.
- Background-text effects — particles passing over, behind, or around a paragraph; masks; blend modes.
The trade is explicit: canvas text is not selectable, not crawlable, and not read by screen readers. Ship it where motion is the point, and keep the DOM target for the article body.
"I am large, I contain multitudes."
Walt Whitman, Song of Myself, Leaves of Grass (1855)Direct Claude
layoutWithLines when width changes; ctx.fillText per cached line each frame
"60 fps text in games"
→
no DOM during the loop — Pretext gives positions, canvas draws
"headline inside the plot"
→
prepareWithSegments once; ctx.font mirrors the prepare font exactly
"baseline, not top-left"
→
baseline = lineHeight * 0.78 for line 0; add lineHeight per line
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';
const FONT_PX = 22;
const FONT = `500 ${FONT_PX}px 'EB Garamond', serif`;
const LINE_HEIGHT_PX = FONT_PX * 1.55;
const ASCENT_RATIO = 0.78; // baseline = lineHeight * ascentRatio
ctx.font = FONT; // mirror Pretext's font onto the canvas
ctx.textBaseline = 'alphabetic';
const prepared = prepareWithSegments(text, FONT);
// Recompute lines only when wrap width actually changes — not every frame.
let lines = [], lastWidth = -1;
function frame(wrapWidth) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawParticles(ctx); // motion lives in the same pixels
if (wrapWidth !== lastWidth) {
lines = layoutWithLines(prepared, wrapWidth, LINE_HEIGHT_PX).lines;
lastWidth = wrapWidth;
}
for (let i = 0; i < lines.length; i++) {
const baselineY = i * LINE_HEIGHT_PX + LINE_HEIGHT_PX * ASCENT_RATIO;
ctx.fillText(lines[i].text, paddingX, paddingY + baselineY);
}
requestAnimationFrame(() => frame(wrapWidth));
}