DOM target
The previous arc taught the walker. This arc asks the consumer's question: once Pretext has given you lines and positions, where do you put them? The first and friendliest answer: the ordinary DOM. A paragraph becomes a stack of block-level spans, one per wrapped line — and nothing else changes about the page.
Drag the width. The lines re-break live. Try to select a phrase and copy it; try tabbing into the region with a screen reader. The text is real text — crawlable, selectable, accessible — and the reflow itself touches zero measurement APIs.
Mechanism
At boot we call prepareWithSegments(text, font) — the rich variant that keeps the segment array around so we can materialize line strings later. On each width change we call layoutWithLines(prepared, width, lineHeightPx), which returns { lineCount, height, lines }. Each line carries its .text, its measured .width, and start/end cursors back into the prepared segments.
We then stamp one <span style="display:block"> per line, setting .textContent to the line's text. Because the spans are block-level in source order, the browser's native selection model does exactly what it has always done — click-drag selects text top to bottom; Cmd+A grabs the paragraph; screen readers announce lines in order. We set the container's min-height from Pretext's returned height, so the box is pre-sized before paint. No getBoundingClientRect, no offsetHeight, no reflow flush.
The handle is width-independent: the same prepared object services every width the slider can produce. The work done on each drag is: one layoutWithLines call plus a .textContent assignment per line. For a paragraph of this size it rounds to zero.
Application
This is the default target for "render via Pretext." The spans are:
- SEO-indexable — search crawlers see the text verbatim, not a shadow copy behind a canvas.
- Accessible — screen readers traverse the DOM; the text is in the DOM.
- Selectable & copyable — the browser's selection engine handles spans natively.
- Reflow-cheap — the hot path is
layoutWithLinesplus span mutations; no synchronous measurement.
What you get in return for the small ceremony of spanifying: you can now per-line style, per-line animate, per-line decorate — drop-caps, hanging indents, line numbers, line-level ripple reveals — without blowing up the flow, because each line is its own box.
"I went to the woods because I wished to live deliberately, to front only the essential facts of life, and see if I could not learn what it had to teach, and not, when I came to die, discover that I had not lived. I did not wish to live what was not life, living is so dear; nor did I wish to practise resignation, unless it was quite necessary."
Henry David Thoreau, Walden (1854)Direct Claude
layoutWithLines + textContent
"still selectable, never measured"
→
render only — don't read back with offsetHeight or getBoundingClientRect
"per-line decoration on live reflow"
→
one <span style="display:block"> per lines[i]; style each independently
"zero reflow cost on resize"
→
set minHeight from Pretext's returned height before paint
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';
const FONT = "400 20px 'EB Garamond', serif";
const LINE_HEIGHT_PX = 20 * 1.55;
// Phase 1: analyze + fit prep — run once.
const prepared = prepareWithSegments(text, FONT);
// Phase 2: render — stamp block-level spans. Selectable. SEO-visible.
function renderAt(width) {
const { lines, height } = layoutWithLines(prepared, width, LINE_HEIGHT_PX);
textRegion.style.minHeight = height + 'px'; // pre-size; no DOM read.
lineStack.textContent = '';
for (const line of lines) {
const span = document.createElement('span');
span.className = 'pt-line';
span.textContent = line.text; // real text; the DOM owns it.
lineStack.appendChild(span);
}
}
widthSlider.addEventListener('input', e => {
renderAt(parseFloat(e.target.value));
});