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/pretextThe 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 heightprepare() 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:
Type text, adjust font and container width — see pretext compute the height in real time.
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 textWhen 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
| Function | Purpose |
|---|---|
prepare(text, font, options?) | One-time measurement pass |
layout(prepared, maxWidth, lineHeight) | Returns { height, lineCount } |
Use-case 2: Full line data
| Function | Purpose |
|---|---|
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 inprepare(), thenlayout()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.readybefore callingprepare()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.