Pretext: The Text Layout Engine That Never Touches the DOM
Hook
Every time you call getBoundingClientRect to measure text height, you’re triggering one of the most expensive operations in the browser. What if you never had to touch the DOM at all?
Context
Text measurement on the web has always been a Faustian bargain. You need accurate heights for virtualized lists, proper scroll anchoring, or layout calculations—but getting those measurements means mounting elements to the DOM and calling APIs like getBoundingClientRect or offsetHeight. These calls trigger layout reflow, forcing the browser to recalculate positions and dimensions for potentially the entire page. At scale, this becomes a performance bottleneck that no amount of debouncing can fully solve.
The problem compounds when you consider modern UI requirements: virtualized lists with variable-height text items, masonry layouts, text flowing around irregular shapes, or even just validating that button labels won’t wrap during CI builds. Canvas’s measureText() only gives you width, and only for single lines. DOM measurement works but kills performance. Server-side rendering has no DOM at all. Pretext exists because the library splits the work into a one-time preparation step and fast arithmetic-based layout calculations, avoiding repeated DOM measurements entirely.
Technical Insight
Pretext’s architecture splits text handling into two distinct phases with radically different performance characteristics. The prepare() step does the heavy lifting once: whitespace normalization (according to 'normal' or 'pre-wrap' rules), text segmentation into grapheme clusters (so emoji like 🚀 and combining character sequences stay intact), bidirectional text analysis (for Arabic, Hebrew, and mixed-direction strings), and width measurement via the Canvas API. This returns an opaque PreparedText handle. The layout() step is where the magic happens—pure arithmetic over cached widths, no DOM, no reflow, just math.
Here’s the simplest use case—calculating paragraph height without ever mounting to the DOM:
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')
const { height, lineCount } = layout(prepared, 320, 20) // 320px width, 20px line height
The font string follows the same format as Canvas’s ctx.font property—a critical detail because Pretext uses the browser’s font rendering engine as ground truth. This isn’t font parsing or glyph-level rendering; it’s strategic delegation. Pretext asks the Canvas API to measure text segments once, caches those measurements, then handles all the line-breaking logic itself.
For more complex scenarios, prepareWithSegments() unlocks manual control. Want to implement text flow where line width changes based on vertical position? Use layoutNextLine() with a cursor:
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext'
const prepared = prepareWithSegments('Long text...', '18px "Helvetica Neue"')
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
while (true) {
// Lines beside an image are narrower
const width = y < imageBottom ? columnWidth - imageWidth : columnWidth
const line = layoutNextLine(prepared, cursor, width)
if (line === null) break
ctx.fillText(line.text, 0, y)
cursor = line.end
y += lineHeight
}
This enables text flowing around arbitrary shapes with programmatic control. Pretext also solves the “multiline shrink-wrap” problem—the README specifically calls this out as “missing from web.” CSS gives you width: fit-content for single lines, but for paragraphs you can now find the minimum width:
import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext'
const prepared = prepareWithSegments('Button label text', '14px Inter')
let maxLineWidth = 0
walkLineRanges(prepared, 200, line => {
if (line.width > maxLineWidth) maxLineWidth = line.width
})
// maxLineWidth is now the tightest container width that fits the text
The walkLineRanges() API is particularly clever—it iterates through lines without building the actual text strings, just giving you widths and cursor positions. For scenarios where you’re testing multiple width constraints (like responsive breakpoints), this skips string concatenation entirely.
Internationalization support appears comprehensive based on the examples. Pretext handles bidirectional text (Arabic and Hebrew mixed with Latin scripts), CJK languages, and proper grapheme clustering (so emoji with modifiers or flags count as single units). The library relies on the browser’s existing infrastructure rather than shipping its own Unicode database.
Gotcha
Pretext is not a complete typography engine, and the README is honest about this. It currently targets what it calls “the common text setup”: white-space: normal or pre-wrap, word-break: normal, and overflow-wrap: break-word. The library doesn’t attempt to handle all CSS text features beyond this scope.
The font string you pass to prepare() must match your actual rendered font properties. You pass something like '16px Inter' to prepare(), and this should align with your CSS font declaration. The font string format follows Canvas’s ctx.font property. There’s no validation to ensure synchronization—Pretext trusts you to keep the font strings aligned. In dynamic theming scenarios or design systems with font tokens, you’ll need careful abstraction to ensure consistency.
Performance gains evaporate if you misuse the API. The README mentions that prepare() takes about 19ms for its benchmark, while layout() takes about 0.09ms. If you’re calling prepare() on every resize or scroll event, you’ve just made performance worse than DOM measurement. The pattern requires discipline: prepare once (or cache prepared values), then call layout() repeatedly with different widths. For virtualized lists, this means preparing all unique text content upfront or on-demand with proper memoization, then laying out only visible items on scroll. Get this wrong, and you’re burning CPU cycles on redundant text segmentation and measurement.
Verdict
Use Pretext if you’re building high-performance virtualized lists with variable-height text items (where guessing heights causes scroll jank), implementing custom layout engines (masonry grids, newspaper-style columns, text flowing around images), rendering text to Canvas or WebGL (for games, data visualizations, or creative coding), or doing server-side layout calculations where DOM doesn’t exist. The README specifically mentions development-time validation as a use case—imagine CI checks that fail if labels overflow. Skip it if your text layout needs are already handled by normal CSS flow (static paragraphs, simple wrapping), you need CSS text features beyond the common setup that Pretext targets, or your team isn’t ready to manually synchronize font strings between Pretext config and CSS declarations—that synchronization requirement demands careful abstraction without explicit tooling support.