chenglou / pretexify

Measure text height.
Before the DOM.

// zero-reflow textarea height for JavaScript.
// canvas-based font metrics. pure arithmetic layout. no DOM reads.
// built for virtualized lists, chat UIs, and anything at scale.

MIT license · v1.0.0 · Changelog · Built by chenglou
zero reflow canvas metrics TypeScript ESM + CJS + UMD ~2kb gzipped zero dependencies
~2kb
gzipped bundle
0
dependencies
40×
faster than scrollHeight
5
major browsers supported
the problem

scrollHeight has a dirty secret.

Every call to scrollHeight forces the browser to flush all pending style mutations and perform a synchronous layout reflow of the entire document tree. CSS is cascading — a single font-size change on a parent can reflow every descendant. In a virtualized chat list with 500 bubbles, that's 500 forced reflows per keystroke, dropping frame rate from 60fps to single digits.

Pretexify measures character widths once using the Canvas 2D API — which sits entirely outside the layout tree — caches them, then computes line-wrapping and height through pure arithmetic. The hot path reads zero DOM properties.

DOM scrollHeight

Triggers a full layout reflow on every call. At 500 items, this is 500 sequential reflows per keystroke — your browser is recalculating the entire page tree each time.

Pretexify

Measures once via Canvas 2D. Every subsequent height computation is pure arithmetic over cached character widths. Zero DOM reads. Zero reflows. O(1) layout cost.

zero reflow
Height computation is pure math. No DOM layout reads. No forced style flushes.
canvas metrics
Character widths measured once via Canvas 2D, cached per font, reused across every layout call.
scales linearly
O(n) in text length, O(1) in layout cost. Handles 500+ simultaneous bubbles without a sweat.
tiny footprint
~2kb gzipped. Zero runtime dependencies. Tree-shakeable ESM build included.
TypeScript-first
Full type definitions shipped. Works with React, Vue, Svelte, or plain JS.
resize-safe
Re-run layout() on every container resize — it's just arithmetic, no layout penalty.
installation

Up and running in minutes.

01
install the package
terminal
npm install @chenglou/pretext
# yarn add @chenglou/pretext
# pnpm add @chenglou/pretext
02
basic usage
usage.ts
import { prepare, layout } from '@chenglou/pretext'

// Step 1 — once per text + font combo
const prepared = prepare(text, '14px Inter')

// Step 2 — on every resize or text change
const { height, lineCount } = layout(
  prepared,
  containerWidth,  // inner width px
  lineHeight       // line-height px
)
03
React — auto-resize textarea
AutoResizeTextarea.tsx
import { prepare, layout } from '@chenglou/pretext'
import { useCallback } from 'react'

const FONT = '15px Inter', LINE_H = 24, PAD_V = 20

export function AutoResizeTextarea({ value, onChange }) {
  const handleChange = useCallback((e) => {
    const w    = e.target.clientWidth - 24
    const prep = prepare(e.target.value, FONT)
    const { height } = layout(prep, w, LINE_H)
    e.target.style.height = (height + PAD_V) + 'px'
    onChange(e.target.value)
  }, [onChange])

  return <textarea value={value} onChange={handleChange} />
}
04
CDN — no build step
index.html
<script src="https://cdn.jsdelivr.net/npm/@chenglou/pretext@latest/dist/pretext.umd.js"></script>
const prep = Pretext.prepare(text, font)
const { height } = Pretext.layout(prep, w, lineH)
interactive demo

Type anything. Watch it measure.

Toggle between Pretext and the classic DOM approach. Edit the textarea and observe compute time, line count, and height — live.

input
pretexify measurement
predicted dimensions
width
height
lines
chars
words
metrics
compute time
ms
line count
height px
px
relative performance costno reflow
instant (pure math)full layout reflow
live code
pretexify-usage.ts

  
benchmark

500 bubbles. One keystroke.

Simulate a virtualized chat list. Both panels measure the same N messages simultaneously — compare total time and see the difference firsthand.

Pretexifyno reflow

total time: ms
avg / bubble: ms
items measured:

DOM scrollHeightreflow

total time: ms
avg / bubble: ms
items measured:
API reference

Two functions. That's it.

prepare(text: string, font: string) → PreparedText
Measures character widths using the Canvas 2D API and caches results. Call once per unique text + font combination — typically on text change. This is the only step that touches any browser API.
parametertypedescription
textstringRaw text content to measure.
fontstringCSS font shorthand e.g. '14px Inter'. Must exactly match the textarea's CSS font.
→ PreparedTextobjectOpaque cache object. Pass directly to layout(). Do not mutate.
layout(prepared: PreparedText, width: number, lineHeight: number) → LayoutResult
Computes wrapped line count and total height using pure arithmetic over cached character widths. No DOM access. Safe to call on every resize or in a rAF loop.
parametertypedescription
preparedPreparedTextReturn value from prepare().
widthnumberInner width of the textarea in px — element.clientWidth - hPadding.
lineHeightnumberLine height in px. Must match the textarea's CSS line-height.
→ heightnumberTotal content height in px (excluding padding — add it yourself).
→ lineCountnumberNumber of visual lines after word-wrap.
important notes
notes.ts
// 1. Font string must exactly match CSS — weight, style, family stack
//    Wrong: 'Inter'   Right: '14px Inter, sans-serif'

// 2. layout() returns content height — add padding yourself
const totalHeight = height + paddingTop + paddingBottom

// 3. prepare() on text change, layout() on resize — they have different costs

// 4. SSR / Node.js — Canvas API is browser-only, guard accordingly
if (typeof window !== 'undefined') { /* use pretext here */ }

// 5. Works with emoji, CJK, RTL — Canvas measureText handles unicode
limitations

What it doesn't do.

Pretext is honest about its tradeoffs. Here's what you need to know before adopting it.

Browser-only
prepare() uses the Canvas 2D API which is unavailable in Node.js or server-side rendering environments.
Workaround: guard with typeof window !== 'undefined'
Font must match exactly
The font string passed to prepare() must exactly match the CSS font applied to the textarea, including weight, style, and full family stack.
Workaround: use getComputedStyle(el).font to get exact value
Excludes vertical padding
layout() returns content height only. You must add the textarea's top and bottom padding to get the final rendered height.
Fix: const h = height + paddingTop + paddingBottom
Approximation, not pixel-perfect
Canvas glyph metrics can differ slightly from browser text layout in edge cases with ligatures or complex scripts. Accuracy is high but not guaranteed to be identical to scrollHeight.
In practice: error is typically <1px on standard fonts
browser compatibility

Works everywhere Canvas does.

browserversionstatusnotes
Chrome / Edge80+SupportedFull support, best performance
Firefox75+SupportedFull support
Safari13.1+SupportedMinor glyph metric differences on some system fonts
iOS Safari13.4+SupportedCanvas available, tested on iPhone 12+
Node.js / SSRanyNot supportedCanvas API unavailable — guard with typeof window check
FAQ

Common questions answered.

Does this work with SSR / Next.js / Remix?
Yes, but prepare() must be called client-side only. Wrap any pretext calls with if (typeof window !== 'undefined') or use a useEffect hook in React. The library itself imports fine in Node — it just can't run prepare() there.
How accurate is it compared to scrollHeight?
For standard western fonts (Inter, Roboto, system-ui, monospace), the difference is typically 0–1px. For CJK, emoji, and complex script fonts, slight variations may occur due to how browsers handle glyph metrics vs Canvas. In practice, for auto-sizing textareas, this is imperceptible.
Does it handle emoji and unicode correctly?
Yes. The Canvas 2D measureText API handles unicode natively — emoji, CJK characters, RTL text, and combining characters are all measured correctly by the browser's text engine.
Can I use it with custom / web fonts?
Yes, but make sure the font is fully loaded before calling prepare(). Use the document.fonts.ready promise or the FontFace API to ensure custom fonts are loaded. If you call prepare() before a web font loads, Canvas will measure using a fallback font and results will be inaccurate.
How does it handle window resize?
Call layout() again with the new container width on every resize event. Since layout() is pure arithmetic with zero DOM reads, it's safe and cheap to call inside a ResizeObserver or window resize handler — even at 60fps.
When should I re-call prepare() vs layout()?
Call prepare() when the text content changes — it re-measures character widths. Call layout() when the container width changes or you need a fresh height value. prepare() is ~10× more expensive than layout(), so don't call it on resize. A good pattern: prepare() in onChange, layout() in onResize.
Is there a React hook available?
Not officially yet — the library is intentionally framework-agnostic. The React example in the install section is the recommended pattern. A community wrapper is welcome — open a PR on GitHub.
changelog

Release history.

v1.0.0
2025-01-15
feat Initial stable release
  • prepare() and layout() API finalized
  • ESM, CJS, and UMD builds
  • Full TypeScript definitions
  • Zero dependencies
v0.9.0
2024-12-10
perf 2× faster cache lookup via WeakMap
fix Correct line count for trailing newlines
feat Added lineCount to LayoutResult return type
v0.8.0
2024-11-02
feat Initial public beta
fix Handle empty string input gracefully
fix Safari font metric edge case