The two-phase dance
In the previous lesson the browser handled all three things every frame: re-wrap the text (analyze), compute line breaks at the new width (fit), and paint the result (render). Pretext pulls the first two out of the browser and into JavaScript, where you control when they happen.
prepare(text, font)— run once. Segments the text, asks the canvas for every segment's width, caches the result. Comparatively expensive.layout(prepared, width, lineHeight)— run many times. Pure arithmetic on the cached widths. Returns{ height, lineCount }.
Same demo, same sliders, same workload. No DOM measurement. Drag the width; crank the workload to 2000. The FPS stays at the top of the dial.
The letter rests on the line. The line rests on the page. The page rests on the table. Nothing, so far, is busy.
Attention is the rarest and purest form of generosity. The mind turns toward what it values and learns by turning — the movement of attention is itself the learning.
Between the word you just read and the next one lies a small country where reading is not happening. Travelers there come back changed.
Mechanism
At startup we call prepare() on every item's text with a fixed font descriptor. Pretext segments the text, probes each segment's width using the browser's own canvas font engine, and stores the result in an opaque handle. Handles are reusable across any width we'll ever ask about.
Inside the width handler, we iterate the workload and call layout(handle, width, lineHeight) on each. Pretext walks the cached segments and applies its word-wrap rules entirely in arithmetic — no DOM reads. Two thousand of those calls fit in well under a frame on any modern machine.
The word-wrap rules Pretext reproduces are the browser's: Unicode line-break classes, CJK keep-all, bidirectional reordering, soft hyphens, emoji grapheme clusters. The canvas measurements are the ground truth, so the line breaks Pretext returns match what the browser would have produced at that width.
Application
Every effect the previous lesson listed as blocked becomes trivial:
- A pull-quote whose font size breathes with the viewport —
layout()per pointermove; no reflow. - A chat bubble that knows its height before insertion —
prepare()the message,layout()at max bubble width, read the height, mount pre-sized. - A masonry grid that reflows continuously — heights are known in JS, placement is arithmetic.
- Text that hugs a draggable image —
layoutNextLineRange()with a per-line width that's a function of the image's current y.
The shape of the unlock is the same every time: you stop asking the DOM for measurements, and do the measurement yourself, once, with a tool that already knows what the DOM would have said.
"Our life is frittered away by detail. … Simplify, simplify."
Henry David Thoreau, Walden (1854)Direct Claude
prepare() on each string up front, cache the handle
"reflow without measuring"
→
call layout(handle, width, lineHeight) per width; no DOM reads
"heights known at scroll time"
→
virtualize using Pretext's returned height, not a DOM measurement
"make it breathe on resize"
→
bind layout() to the resize observer; animate the computed height
import { prepare, layout } from '@chenglou/pretext';
// Phase 1: analyze — run once per string.
const handles = items.map(text =>
prepare(text, "400 20px 'EB Garamond', serif")
);
// Phase 2: fit — run on every input event. Pure arithmetic.
widthSlider.addEventListener('input', (e) => {
const width = parseFloat(e.target.value);
for (let i = 0; i < workload; i++) {
const h = handles[i % handles.length];
const { height } = layout(h, width, 20 * 1.55);
// height is ready for anything downstream — no DOM read.
}
textRegion.style.width = width + 'px';
});