Pretext Lab
20Canvas target
Arc 7 · Rendering Targets

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.

density · 48 particles
— fps
520
48

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:

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

"text inside a canvas scene" 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
Next: the same pattern with <svg><text> nodes — vector, infinitely zoomable.
walker on canvas: layoutWithLines + ctx.fillText 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));
}