JavaScript Text Measurement Without DOM Reflow: Meet Pretext

deep-diveMarch 29, 2026

Measuring the height of a block of text in JavaScript has always felt like one of those browser APIs that should exist but somehow doesn't. The DOM can render text perfectly, but asking it "how tall would this text be at 400px wide?" is surprisingly hard to do correctly.

Enter pretext — a pure JS library that does exactly this, without any DOM tricks.

The Problem: DOM-Based Text Measurement Is Painful

The naive approach is to throw a <div> on the page and measure it:

function getTextHeight(text, font, maxWidth) {
  const div = document.createElement('div')
  div.style.cssText = `
    position: absolute;
    visibility: hidden;
    font: ${font};
    width: ${maxWidth}px;
    white-space: pre-wrap;
  `
  div.textContent = text
  document.body.appendChild(div)
  const height = div.getBoundingClientRect().height
  document.body.removeChild(div)
  return height
}

This works. But it's terrible for performance at scale. Every call to getBoundingClientRect() — and to a lesser extent, adding/removing DOM nodes — forces a synchronous layout reflow. The browser has to flush its pending layout work, recalculate styles, and reflow the relevant parts of the document.

In a virtualized list with hundreds of rows, or a canvas renderer computing text bounds before drawing, calling this in a loop will tank your frame rate.

There are workarounds:

  • Batch reads/writes with requestAnimationFrame — helps, but adds async complexity
  • ResizeObserver + off-screen elements — still DOM-based, still triggers layout
  • Cache computed heights — works until the window resizes or font loads change

All of them are fighting the fundamental problem: DOM layout is synchronous and expensive, and the browser doesn't expose a way to compute text layout without doing it.

What Pretext Does Differently

Pretext sidesteps DOM layout entirely. It uses canvas.measureText() — which the browser exposes as a relatively cheap call — to measure individual text segments. Once a text string is "prepared", subsequent layout calls are pure arithmetic over cached measurements.

npm install @chenglou/pretext

The basic usage is two calls:

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

// One-time: measure all the segments, build a layout model
const prepared = prepare('Hello, world! This is some text.', '16px Inter')

// Hot path: pure math, no DOM, runs in microseconds
const { height, lineCount } = layout(prepared, 400, 24) // 400px wide, 24px line height

prepare() does the expensive work once: it normalises whitespace, segments the text (handling Unicode correctly, including emojis, CJK, RTL, and mixed-bidi text), measures the segments with canvas, and returns an opaque handle.

layout() is the cheap path: given a max width and line height, it runs the wrapping algorithm over the cached measurements. No DOM. No layout. Just arithmetic.

On their benchmark: prepare() takes ~19ms for a batch of 500 texts. layout() takes ~0.09ms for the same batch. That's two orders of magnitude cheaper for the hot path.

Try It Live

Here's pretext running directly in your browser. Type some text, adjust the font size and container width, and watch the height update in real time. Compare it to the actual rendered text below to verify the accuracy:

⚡ Live Pretext Demo

Type text, adjust font and container width — see pretext compute the height in real time.

Pretext says
0
Lines
0px
Height
400px
Width
The quick brown fox jumps over the lazy dog. This is a second line to demonstrate wrapping behaviour across multiple lines.

Notice how the computed height and actual rendered height match (or are very close — there can be a pixel or two of rounding depending on the browser's subpixel rendering).

Use Case 1: Just Get the Height

The prepare() + layout() API is perfect when you just need height, not individual line data. The classic example is a virtualized list:

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

type Item = { id: string; text: string }

function measureRows(items: Item[], font: string, containerWidth: number, lineHeight: number) {
  return items.map(item => {
    const prepared = prepare(item.text, font, { whiteSpace: 'pre-wrap' })
    const { height } = layout(prepared, containerWidth, lineHeight)
    return { id: item.id, height }
  })
}

// In a virtualized list (react-window, TanStack Virtual, etc.)
const rowHeights = measureRows(myData, '14px Inter', 600, 20)

Crucially, you can do this before rendering — measure all rows off-screen, build a height map, then hand it to your virtual list. No ResizeObserver, no DOM probe elements, no layout reflow.

For whiteSpace: 'pre-wrap' mode (textarea-like text where \t, spaces, and \n are preserved), pass the option:

const prepared = prepare(userInput, '16px Inter', { whiteSpace: 'pre-wrap' })
const { height } = layout(prepared, textareaWidth, 24)

Use Case 2: Full Line Control

When you need to render text yourself (canvas, SVG, WebGL), you want individual lines, not just a height. Switch to prepareWithSegments() + layoutWithLines():

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

const prepared = prepareWithSegments('AGI 春天到了. بدأت الرحلة 🚀', '18px "Helvetica Neue"')
const { lines, height, lineCount } = layoutWithLines(prepared, 320, 26)

// Render each line to canvas
for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}

Each line in the result gives you the text content and its measured width. This is exactly what you need for a custom text renderer.

For more advanced cases — like flowing text around a floated image where line widths change — there's layoutNextLine():

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

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

let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0

while (true) {
  // Lines beside the image are narrower; lines below it get full width
  const width = y < image.bottom ? columnWidth - image.width : columnWidth
  const line = layoutNextLine(prepared, cursor, width)
  if (line === null) break

  ctx.fillText(line.text, 0, y)
  cursor = line.end
  y += lineHeight
}

This kind of variable-width line layout would be nearly impossible to do correctly with DOM measurements. Pretext makes it straightforward.

There's also walkLineRanges() for cases where you want to binary-search for an optimal width (e.g. "shrink-wrap" a text block to its tightest fit) without building the actual line strings:

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

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

let maxLineWidth = 0
walkLineRanges(prepared, 320, (line) => {
  if (line.width > maxLineWidth) maxLineWidth = line.width
})

// maxLineWidth is now the tightest container width that fits the text

When to Use Pretext

Pretext fills a specific gap. Use it when:

✅ Building a virtualized list — you need row heights before rendering. Pretext lets you pre-calculate them in a single pass without any DOM work.

✅ Rendering to canvas or SVG — you need actual line boundaries for fillText() calls. layoutWithLines() gives you exactly that.

✅ Preventing layout shift on text load — if you need to pre-reserve space for text that will load asynchronously (e.g. streaming content, font swaps), pretext can calculate the eventual height immediately.

✅ Scroll position anchoring — when content above the viewport changes height, you need to adjust scrollTop to keep the user's view stable. Knowing exact heights in advance makes this much easier.

✅ Server-side layout — pretext (eventually) supports server-side rendering, where there is no DOM at all.

✅ RTL, CJK, emoji, mixed scripts — pretext handles Unicode correctly, including bidirectional text and complex grapheme clusters that break naive character-counting approaches.

When NOT to Use Pretext

For most typical UI work, you don't need pretext:

❌ Simple CSS layouts — if you're rendering text in a regular flow layout and you don't need to know heights in advance, just let the browser do its job. CSS is better at this.

❌ One-off measurements — if you measure text once during setup, the complexity isn't worth it. A single getBoundingClientRect() on a hidden element is fine.

❌ When font loading is unreliable — pretext uses canvas for font measurements. If your font hasn't loaded yet when prepare() runs, measurements will be wrong. Use document.fonts.ready to ensure fonts are loaded first.

// Always wait for fonts before calling prepare()
await document.fonts.ready
const prepared = prepare(text, '16px CustomFont')

The API At a Glance

Use-case 1: Height only

FunctionPurpose
prepare(text, font, options?)One-time measurement pass
layout(prepared, maxWidth, lineHeight)Returns { height, lineCount }

Use-case 2: Full line data

FunctionPurpose
prepareWithSegments(text, font, options?)Like prepare(), but richer output
layoutWithLines(prepared, maxWidth, lineHeight)Returns { height, lineCount, lines[] }
walkLineRanges(prepared, maxWidth, onLine)Low-level: iterates line widths without building strings
layoutNextLine(prepared, cursor, maxWidth)Iterator: one line at a time with variable widths

The font parameter uses the same format as canvas.font — e.g. "16px Inter", "bold 14px 'Helvetica Neue'". Make sure it matches your CSS font shorthand exactly.

Summary

  • Measuring multiline text height in the browser has always required DOM hacks that trigger layout reflow
  • Pretext uses canvas.measureText() to measure segments once in prepare(), then layout() is pure arithmetic
  • Use prepare() + layout() when you just need height and line count
  • Use prepareWithSegments() + layoutWithLines() when you're building a custom renderer and need individual lines
  • Ideal for virtualised lists, canvas/SVG rendering, scroll anchoring, and server-side layout
  • Wait for document.fonts.ready before calling prepare() to ensure accurate measurements
  • Not needed for regular CSS layouts — only pull it in when you genuinely need pre-render height calculations

Pretext fills a real gap. For anyone building custom text-heavy UIs — virtualised lists, collaborative editors, canvas renderers — it's worth adding to your toolkit.