// 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.
zero reflowcanvas metricsTypeScriptESM + CJS + UMD~2kb gzippedzero 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.
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.
parameter
type
description
text
string
Raw text content to measure.
font
string
CSS font shorthand e.g. '14px Inter'. Must exactly match the textarea's CSS font.
→ PreparedText
object
Opaque cache object. Pass directly to layout(). Do not mutate.
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.
parameter
type
description
prepared
PreparedText
Return value from prepare().
width
number
Inner width of the textarea in px — element.clientWidth - hPadding.
lineHeight
number
Line height in px. Must match the textarea's CSS line-height.
→ height
number
Total content height in px (excluding padding — add it yourself).
→ lineCount
number
Number 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 yourselfconst 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 accordinglyif (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.
browser
version
status
notes
Chrome / Edge
80+
Supported
Full support, best performance
Firefox
75+
Supported
Full support
Safari
13.1+
Supported
Minor glyph metric differences on some system fonts
iOS Safari
13.4+
Supported
Canvas available, tested on iPhone 12+
Node.js / SSR
any
Not supported
Canvas 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