Floating rows of multilingual glyphs aligning on measurement guides, with a calm gray-blue cat watching from a shelf in the background.

Pretext: Measure Text Without Waking the DOM

The Gray Cat
The Gray Cat
0 views

Measuring text in the browser usually means asking the DOM for an answer it hates to give. You insert a hidden element, set its content, then read getBoundingClientRect() or offsetHeight to find out how tall it became. Each of those reads can force a synchronous layout reflow, one of the most expensive things a browser does, and when you do it in a loop, the page stutters. Pretext takes that whole job away from the DOM. It is a pure JS/TS library that measures and lays out multiline text on its own, so you can compute exact heights and line breaks in real time without ever triggering a reflow.

The library comes from Cheng Lou, a former React core team member best known for react-motion, the spring-physics animation library that inspired react-spring and Framer Motion. Pretext shipped while he was building rendering tooling at Midjourney, which explains why someone would write a text layout engine from scratch. It went from a fresh repository to tens of thousands of stars within weeks of release, and despite the buzz it is still an early 0.0.x project, so expect the API to keep moving.

Why It Runs Circles Around a Measuring Div

The trick is a clean two-phase split. The slow, careful work happens once and gets cached; the work you repeat on every resize is just math.

In the preparation phase, Pretext normalizes whitespace, segments the text with Unicode rules via Intl.Segmenter, applies glue and break rules, and measures each segment's width with the Canvas measureText() API. Crucially, measureText() does not trigger reflow the way DOM reads do. That measurement work costs somewhere between 0.1ms and 1ms depending on text length, and the result is an opaque prepared handle that lives for the lifetime of the page.

After that, computing the height or line count at any given width is pure arithmetic over the cached segment widths. That is why the project reports being hundreds of times faster than DOM-based measurement, roughly 300 to 600 times in community figures. The golden rule that follows from this design: never re-run preparation for the same text and font, because re-segmenting and re-measuring defeats the whole point. On resize, you only re-run the cheap layout step.

What You Get Out of the Box

  • Multilingual and international by default. It is tested against Chinese, Arabic with full right-to-left bidi, emoji, and mixed scripts inside a single string.
  • Grapheme-aware line breaking. It will not slice an emoji ZWJ sequence or a combining mark in half.
  • CSS fidelity. It matches white-space: normal and pre-wrap, word-break: normal and keep-all, overflow-wrap: break-word, numeric letter-spacing, soft hyphens, and tab expansion, using the browser's own font engine as the accuracy oracle.
  • Multiple render targets. Feed the results into DOM, Canvas, SVG, or WebGL, with server-side rendering on the roadmap.
  • Rich inline runs. Mix different fonts, letter-spacing, and break rules across segments on a single line.
  • Tiny and dependency-free. Around 15KB gzipped with zero dependencies, and it runs in browsers, Node, Deno, Bun, Cloudflare Workers, and Web Workers.

Getting It Installed

Pretext is published under a scope, so install it by its full package name.

npm install @chenglou/pretext
yarn add @chenglou/pretext

There is no runtime dependency to worry about. The only environmental requirements are Intl.Segmenter and the Canvas 2D API, both of which are broadly available in modern browsers and runtimes.

Asking How Tall a Paragraph Will Be

The most common task is the simplest: you have some text, a font, and a width, and you want the height before anything renders. That is two function calls.

import { prepare, layout } from '@chenglou/pretext'

// One-time, cached work. The font string is the CSS font shorthand,
// the same string you would hand to canvas ctx.font.
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')

// Cheap arithmetic: (prepared, maxWidth, lineHeight)
const { height, lineCount } = layout(prepared, 320, 20)

console.log(height, lineCount) // exact, no DOM layout, no reflow

The prepare() call does all the segmentation and measurement once. The layout() call is the part you can safely run on every resize event, every animation frame, or every width guess, because it is only doing arithmetic against the cached widths. Notice the input string mixes Latin, Chinese, Arabic, and an emoji, and it all just works.

Matching a Textarea

If you are sizing a textarea or any pre-formatted block, you want spaces, tabs, and newlines to count rather than collapse. Pass whiteSpace: 'pre-wrap', which mirrors the CSS value of the same name.

import { prepare, layout } from '@chenglou/pretext'

const prepared = prepare(textareaValue, '16px Inter', {
  whiteSpace: 'pre-wrap',
})

const { height } = layout(prepared, textareaWidth, 20)
// set the textarea's height to `height` and it grows exactly with the content

The options object also accepts wordBreak set to 'normal' or 'keep-all', matching CSS word-break, and a numeric letterSpacing in pixels that matches CSS letter-spacing. Because these map directly onto the canvas font model, what you measure is what the browser would paint.

Drawing The Lines Yourself

The height-only path is perfect for DOM sizing, but if you are rendering text onto a Canvas, into SVG, or through WebGL, you need the individual lines and their geometry. For that, prepare with segments and lay out with lines.

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments(
  'AGI 春天到了. بدأت الرحلة 🚀',
  '18px "Helvetica Neue"',
)

const { height, lineCount, lines } = layoutWithLines(prepared, 320, 26)

let y = 26
for (const line of lines) {
  // line.text, line.width, line.start, line.end are all available
  ctx.fillText(line.text, 0, y) // or build an SVG <text> node
  y += 26
}

Each entry in lines is a LayoutLine carrying the line's text, its width, and start and end cursors that point back into the original segments. You hand line.text straight to ctx.fillText or an SVG node, and you already know the width for alignment without measuring again.

Squeezing The Last Drop of Performance

When you are laying out thousands of lines, even allocating strings for each one adds up. Pretext exposes a zero-allocation path that walks line ranges without materializing the text until you actually need it.

import {
  prepareWithSegments,
  walkLineRanges,
  measureLineStats,
  materializeLineRange,
} from '@chenglou/pretext'

const prepared = prepareWithSegments(longBody, '16px Inter')

// Quick stats without building any line strings
const { lineCount, maxLineWidth } = measureLineStats(prepared, 480)

// Walk every line range; build the string only for the ones you draw
walkLineRanges(prepared, 480, (range) => {
  if (isVisible(range)) {
    const line = materializeLineRange(prepared, range)
    drawLine(line)
  }
})

measureLineStats() gives you lineCount and maxLineWidth without producing a single line string, which is exactly what a virtualized list needs to know its total height. walkLineRanges() lets you iterate over every line as a lightweight range and call materializeLineRange() only for the lines you are about to paint. There is also layoutNextLineRange(), an iterator-style call useful for variable-width layouts like wrapping text around floating elements, and measureNaturalWidth() for the widest un-wrapped line.

Mixing Fonts On A Single Line

Real typography often mixes styles within one line: a bold label next to regular body text, or an inline code span in a monospace face. Pretext models this with rich inline runs, where each run carries its own font and rules.

import { prepareRichInline } from '@chenglou/pretext'

const prepared = prepareRichInline([
  { text: 'Shipping ', font: '16px Inter' },
  { text: 'pretext', font: 'bold 16px "JetBrains Mono"' },
  { text: ' to production today', font: '16px Inter', letterSpacing: 0.2 },
])

// Then lay out with the rich-inline family:
// layoutNextRichInlineLineRange, walkRichInlineLineRanges,
// materializeRichInlineLineRange, measureRichInlineStats

Each item accepts text, font, an optional letterSpacing, a break rule of 'normal' or 'never', and an optional extraWidth for things like inline icons. The rich-inline layout functions mirror the plain ones, so the mental model stays the same: prepare once, then walk or materialize lines as you draw. When you are done with a long-running session, clearCache() releases accumulated font and variant caches, and setLocale() lets you tune the segmentation locale.

Where It Shines, And Where It Stops

The strongest fits are the ones where DOM measurement traditionally falls apart. Virtualized and occluded lists get exact row heights up front, which eliminates the scroll jumps that come from guessed heights. Userland layout engines like masonry can lean on real measurements instead of CSS hacks. Canvas, WebGL, and SVG text rendering for games, data visualization, and design tools finally get CSS-accurate line breaking. And because it runs without a browser at all, it is handy for verifying at build time or in an agent loop that a button label will not overflow to a second line.

It is worth being clear about the boundaries too. Pretext measures and breaks lines; it does not rasterize glyphs, so it is not a replacement for a font rendering engine. It only understands text features expressible through the canvas font shorthand, which means it does not separately model font-feature-settings or font-variation-settings. The system-ui font is unsafe to rely on for measurement on macOS, so name your fonts explicitly. And since it sits at 0.0.x, treat the API as something that may still shift under you.

The Takeaway

Pretext is a focused tool that fills a real gap: CSS-accurate, multilingual, multiline text measurement and line breaking in about 15KB, with zero dependencies and no reflow. The two-phase design, prepare once and lay out cheaply forever, is the kind of idea that feels obvious only after someone shows it to you. If you have ever fought a hidden measuring div, watched a virtualized list jump around, or needed to draw wrapped text onto a canvas, it is well worth a look, with the only caveat being its early version number.