Zero-configuration Vite plugin that measures text off the main thread in a Web Worker (via
@chenglou/pretext) — reserve height to stop layout shift, shrink-wrap to content width, style by wrapped line count, or just read the measurements with zero forced layout.
Try the live demo → — the
landing page renders the same content in two columns; only the left has
data-pretext. Press Start streaming and the right column's
unexpected shift counter climbs while the left holds at zero. The bundled
FPS test (#fps deep-links
straight into it) benchmarks cached style.minHeight reads against
forced-layout offsetHeight reads over a heterogeneous corpus.
The Playground → shows the rest of the surface, each demo isolated:
- Output modes —
height,width,lines, andnoneside by side, so you can see what each writes. lines-mode card — a resizable heading whose typography re-styles by wrapped line count via the inline--pretext-line-countvariable, with no JS in the styling path. Drag its corner to flip the line count and watch it respond.- Measurement chips — each element's
naturalWidth · height · lineCount, surfaced through thepretext:measuredDOM event. - Webfont swap — change a typeface and every marked element re-measures on
document.fonts.ready.
- Zero configuration. Drop the plugin in, mark elements, ship.
- Off-main-thread measurement. Worker-based; never blocks paint.
- Four output modes.
heightfor CLS-prevention (default),widthfor shrink-wrap,linesfor line-count CSS hooks,nonefor bring-your-own rendering. See Modes. - Opt-in per element or per subtree. Add
data-pretextto a paragraph or to a whole article — the marker is smart enough to walk into block descendants on its own. - Reactive. Re-measures on font load, resize, and visibility change.
- No bundle bloat when unused. Production builds emit nothing if the marker is absent from your source.
npm install -D vite-pretext
# or: pnpm add -D vite-pretext
# or: yarn add -D vite-pretext// vite.config.ts
import { defineConfig } from 'vite';
import { vitePretext } from 'vite-pretext';
export default defineConfig({
plugins: [vitePretext()],
});Mark text elements you want measured. The marker is smart: applied to a leaf,
it measures that element; applied to a container with block-level children,
it walks in and measures the text-bearing leaves (p, h1–h6, li, td,
th, figcaption, blockquote, cite, dt, dd).
<!-- per element -->
<p data-pretext>Long paragraph that should reserve its height…</p>
<!-- or once on a parent — the orchestrator finds the leaves -->
<article data-pretext>
<h1>Headline</h1>
<p>Lede…</p>
<p>Body…</p>
</article>That's it for the default flow: pretext scans your source for the marker,
spawns a worker, measures off-thread, and writes min-height to each
element so it holds its shape during webfont swap, fluid resize, and async
content fill. Switch to width-fit, line-count, or event-only via
data-pretext-mode below.
Each marker writes one of four kinds of output, picked via
data-pretext-mode. The default is height.
| Mode | Inline style applied | CSS variables | Use case |
|---|---|---|---|
height (default) |
min-height |
--pretext-mode, --pretext-height, --pretext-line-count |
Reserve vertical space — prevent CLS during font swap, async fill, resize. |
width |
width = naturalWidth + padding + border |
adds --pretext-natural-width, --pretext-max-line-width |
Shrink-wrap to content width — chat bubbles, badges, button labels. |
lines |
(none) | --pretext-mode, --pretext-line-count |
Style by wrapped-line count without forcing a layout read. |
none |
(none) | (none) | Bring-your-own rendering — only the pretext:measured event + getMeasurement(el). |
lines and none skip the inline write but the pretext:measured event
still fires and getMeasurement(el) still returns the cached snapshot. Use
the plugin-wide applyStyles: false option (or per-element
data-pretext-apply-styles="false") to suppress the inline write while
keeping the CSS variables for height / width modes.
<!-- height (default): reserve min-height -->
<p data-pretext>Long paragraph that should reserve its height…</p>
<!-- width: shrink-wrap a button to its label -->
<button data-pretext data-pretext-mode="width">Buy now</button>
<!-- lines: drive CSS off line count -->
<style>h2[style*="--pretext-line-count: 1"] { font-size: 2.5rem; }</style>
<h2 data-pretext data-pretext-mode="lines">Hangs on whether it wraps.</h2>
<!-- none: custom render path; subscribe via the event or the JS API -->
<div data-pretext data-pretext-mode="none">…</div>
<script>
document.querySelector('div[data-pretext]')
.addEventListener('pretext:measured', (e) => render(e.detail));
</script>Every marker accepts the optional data-pretext-* attributes below. All are
optional; absent means "use the default."
| Attribute | Values | Default | Notes |
|---|---|---|---|
data-pretext |
(presence) | — | The marker. Required to opt in. |
data-pretext-mode |
height / width / lines / none |
height |
See Modes. |
data-pretext-text |
string | uses el.textContent |
Override the text used for measurement — useful for skeleton-then-fill. |
data-pretext-white-space |
pre-wrap |
normal |
Forwarded to pretext.prepare(). |
data-pretext-word-break |
keep-all |
normal |
Forwarded to pretext.prepare() — common for CJK content. |
data-pretext-letter-spacing |
px number | auto-detected from getComputedStyle |
Override only when the computed style misses it. |
data-pretext-apply-styles |
true / false |
inherits the plugin's applyStyles option |
Per-element opt-out of the inline-style write. |
<!-- async-fill skeleton: measure the eventual text before it arrives -->
<p data-pretext data-pretext-text="Loading post body…"></p>
<!-- pretext.prepare() forwards -->
<pre data-pretext data-pretext-white-space="pre-wrap">{{raw}}</pre>
<p data-pretext data-pretext-word-break="keep-all" lang="ja">…</p>
<h1 data-pretext data-pretext-letter-spacing="2">Tracked headline</h1>
<!-- keep CSS vars + events but skip the inline min-height/width write -->
<p data-pretext data-pretext-apply-styles="false">…</p>Pretext earns its keep in four patterns:
- Off-main-thread layout queries. Reading
offsetHeight/getBoundingClientRectafter a style write forces synchronous layout. With pretext, you readel.style.minHeight(orgetMeasurement(el)?.height) instead — a layout-free lookup. Useful for virtual lists, masonry, or any code that needs row heights to position other items. - Pre-reservation under async content. When content arrives lazily (fetch, then fill), pages without reserved heights jump the moment text arrives. Put the text in DOM up front (visually masked), mark it, and pretext reserves the height before paint.
- Stable heights across font swaps. Pretext re-measures every marked
element when webfonts arrive (
document.fonts.ready) and on resize (rAF-batchedResizeObserver). - Shrink-wrap to content width. With
data-pretext-mode="width", an element's inlinewidthis set to its natural content width plus horizontal padding and border. Buttons, badges, and chat bubbles size cleanly to their text — and re-fit when labels swap (i18n, copy A/B) withoutinline-block+white-space: nowraphacks.
What it does not do:
- Prevent shift from inserting fully-rendered HTML synchronously — the browser pays the natural-height cost either way.
- Server-side rendering. Min-heights are applied during hydration in a microtask, not baked into the HTML payload.
All options are optional; the defaults work for typical projects.
vitePretext({
// Used only when getComputedStyle cannot resolve a value yet.
fallbacks: {
fontFamily: 'system-ui, sans-serif',
fontSize: '16px',
lineHeight: '1.5',
},
// File patterns scanned for the marker.
include: [/\.[jt]sx?$/, /\.vue$/, /\.svelte$/, /\.html$/],
// Emit build warnings when layout-forcing DOM reads (offsetHeight,
// clientHeight, scrollHeight + width siblings, getBoundingClientRect,
// getClientRects, offsetTop/Left) appear in your source — those are
// exactly the calls pretext is designed to replace. Each warning carries
// an API-tailored replacement hint pointing at `getMeasurement(el)`,
// the matching CSS variable, or — for position reads — rAF batching.
// Suppress per-site with `// @vite-pretext-ignore` on the same line or
// the line above (block-comment `/* @vite-pretext-ignore */` also works).
// Set `false` to silence globally. Heuristic; may flag matches inside
// strings.
warn: true,
// Whether the orchestrator writes inline styles (min-height / width) to
// measured elements. Set false to keep CSS variables, events, and JS API
// but skip the inline-style application — apply heights yourself in CSS.
// Each marker can override per-element via data-pretext-apply-styles.
applyStyles: true,
// Extend the smart-marker walk's tag knowledge. Always extends, never
// replaces; tag names are case-insensitive. Use `textLeaf` for custom
// elements you want measured (auto-promoted into the block set so a
// parent of only-custom-leaves still walks in). Use `block` for custom
// containers that should make their parent walk in. Avoid adding
// inline-by-default tags like `<a>`, `<button>`, `<label>` — they break
// the very common `<p>See <a>here</a></p>` measurement.
tags: {
textLeaf: [], // e.g. ['my-headline', 'app-quote']
block: [], // e.g. ['app-section', 'card-body']
},
});The default tag sets cover standard HTML5:
- Leaves (measured):
p,h1–h6,li,td,th,caption,figcaption,blockquote,cite,dt,dd,summary,legend. - Containers (walked into): the leaves above plus
ul,ol,dl,table,thead,tbody,tfoot,tr,article,section,aside,div,nav,header,footer,main,figure,pre,address,hr,form,fieldset,details,dialog.
Runtime hook for tooling, frameworks, and demos.
// lifecycle
window.__vitePretext.setEnabled(false); // drop reserved styles, simulate "no plugin"
window.__vitePretext.setEnabled(true); // re-measure and re-apply
window.__vitePretext.remeasureAll(); // force a re-measurement of every tracked element
window.__vitePretext.getStats(); // { pendingCount, completedCount, suppressedCount, lastMeasureMs }
// per-element measurement access
window.__vitePretext.getMeasurement(el);
// → { height, lineCount, naturalWidth?, maxLineWidth? } | undefined
window.__vitePretext.observe(el, (m) => { /* fires on each measurement */ });
window.__vitePretext.observeAll((el, m) => { /* every element, everywhere */ });The orchestrator writes inline CSS variables onto each measured element
(unless mode="none"):
--pretext-mode: <height|width|lines|none>
--pretext-height: <px, no unit>
--pretext-line-count: <integer>
--pretext-natural-width: <px> /* mode="width" only */
--pretext-max-line-width: <px> /* mode="width" only */
Use them in CSS:
.card[style*='--pretext-line-count: 1'] { font-size: 1.5rem; }
.card { height: calc(var(--pretext-height) * 1px); }A bubbling CustomEvent<Measurement> dispatches on each marker after every
measurement. Equivalent to observe(el, ...):
el.addEventListener('pretext:measured', (e) => {
console.log(e.detail.lineCount);
});Source for the live demo lives at
examples/vanilla-fps-demo. It renders the
same heterogeneous corpus (headings, blockquotes, tables, lists, threads
with nested replies, outlines) in both columns; the left has the marker,
the right doesn't.
git clone https://github.com/BALOTIAS/vite-pretext.git
cd vite-pretext
pnpm install
pnpm dev # opens http://localhost:5173/Vite 8+. Vite 7 backport is on the roadmap but not guaranteed. No support for Vite 6 or older.
Driving CSS off the measurement variables — most usefully
--pretext-line-count in lines mode — lets you restyle an element by how
its text wraps, with no JavaScript in the styling path. If those rules change
a property that affects line breaking (font-size, font-family,
letter-spacing, line-height), the naïve sequence "measure → restyle → the
element re-wraps → re-measure" would loop forever at widths near a line-break
boundary.
pretext breaks this at the root. Its measurement depends only on an element's
width; a restyle that changes line breaking changes the element's
height, not its width. So the orchestrator ignores ResizeObserver
notifications whose width is unchanged since the last measurement — they're
self-induced and carry no new information. A genuine resize (the container
actually gets wider or narrower) still re-measures, and the explicit triggers
(document.fonts.ready, remeasureAll()) bypass the guard entirely. The
number of dropped notifications is exposed as getStats().suppressedCount.
The result: line-count-driven typography settles to one value per width and holds — at a boundary width the pinned value is whatever the element first measured there. Stable, never oscillating.
- Drive these freely (won't re-trigger line breaking):
color,text-decoration,font-weightwithin the same metrics,opacity,transform,background,border. - Avoid driving these from a measurement variable: anything that changes
the element's own width (
width, horizontalpadding,max-width). Width-stable suppression can't see those, so they can still loop. - One caveat: if a theme toggle swaps
font-familywithout changing any element's width, the guard won't pick it up automatically — call__vitePretext.remeasureAll()from the toggle handler.
Rough priority, top → bottom. Ideas in sketch form; nothing committed.
The bootstrap-chunk emit pattern relies on Rolldown's emitFile; on Vite 7 I'd
ship an ESBuild-based fallback behind a configResolved version branch.
Broadens the addressable user base by a lot. Vite 6 stays out — too old.
I'm planning more framework-specific demos to show how pretext can slot into real apps:
- React (threaded comments with
react-virtuoso) - Vue (e-commerce grid with reactive filters)
- Angular (data dashboard with CDK virtual scroll via Analog)
- Svelte (long-form reader with progressive sections)
Each picks the use case where that framework's idioms put pressure on the surface pretext fixes.
A data-pretext-mode="balance" that binary-searches widths via pretext's
walkLineRanges to find one that produces balanced-line output. Equivalent
to CSS text-wrap: balance but works in older browsers and stays
consistent across font swaps.
data-pretext-max-lines="2" / data-pretext-max-width="240" constraints
verified at build time using pretext server-side. Fails the build with
file/line diagnostics if any element overflows. Catches the "this German
translation broke our header" class of bug before deploy. Needs
node-canvas (or stable Node OffscreenCanvas) as an optional dep.
Run pretext on the server during render, bake min-height into the HTML
payload before it ever hits the browser. Eliminates the hydration-microtask
delay entirely. Would ship as a separate vite-pretext/ssr entry rather
than altering the runtime.
Wrap @chenglou/pretext/rich-inline for layouts that mix prose with
mentions, chips, inline code spans, and other atomic items. Useful for
chat composers and rich text editors. Narrow but underserved use case.
Expose walkLineRanges, layoutWithLines, and the cursor APIs through
the runtime hook so callers can drive line-level rendering themselves —
animated per-line reveals, search-result highlighting, custom text
effects. Probably stays out: anyone going this deep should call
@chenglou/pretext directly.
Speculative. Pretext's segment preparation could parallelise across
compute shaders inside the worker. Plugin auto-detects WebGPU support and
injects a .wgsl-accelerated worker variant. Far-future; only worth it
if a benchmark proves the win.
MIT, see LICENSE.md.