<!DOCTYPE html>
import { clearCache, layout, prepare } from "@chenglou/pretext"
static type Message = {
id: string
author: string
body: string
time: string
type: 'agent' | 'user'
}
static const BODY_FONT = '14px Arial'
static const BODY_LINE_HEIGHT = 20
static const BODY_LETTER_SPACING = 0
static const BUBBLE_BORDER_WIDTH = 2
static const BUBBLE_MAX_WIDTH = 680
static const BUBBLE_X_PADDING = 28
static const BUBBLE_Y_PADDING = 24
static const META_BODY_GAP = 6
static const META_HEIGHT = 18
static const ROW_X_PADDING = 32
static const ROW_Y_PADDING = 16
static const DEFAULT_VIEWPORT_WIDTH = 760
static const sampleBodies = [
'The expensive part of variable-height virtualization is usually discovering the height after the row has already rendered. Pretext lets this example calculate the message height from the text, font, width, and line-height before the DOM node exists.',
'This row includes hard breaks.\n\nThe CSS uses white-space: pre-wrap, so the Pretext prepare call uses the same option. Keeping those two in sync is the difference between a stable scroll position and a slow drip of measurement corrections.',
'Resize the page and the example reruns layout() for the new content width. It does not rerun prepare() unless the text or font inputs change.',
'Rows still use TanStack Virtual for scroll state, range extraction, absolute positioning, overscan, and scrollToIndex. Pretext only owns text measurement.',
'If a row contains media, embeds, block markdown, or custom components, let the virtualizer measure that row with measureElement or call resizeItem when the non-text content resolves.',
'The practical pattern is to make one part of the system responsible for each row size. For these text-only rows, Pretext provides the size. For mixed content, measured DOM can be the fallback.',
'A named font is intentional here. Pretext uses canvas text measurement, and named fonts are easier to keep aligned with CSS than system font aliases.',
'The virtualizer is reset after fonts finish loading. That clears Pretext caches and recalculates row sizes using the final font metrics.',
'Pretext returns zero height for an empty string. If your UI still renders an empty text block as one line, clamp the measured body height to at least one line-height.',
'This is a synthetic chat log, but the same approach works for AI streams, activity feeds, notification centers, changelogs, comment threads, and other text-heavy timelines.',
]
static const MESSAGES = Array.from({ length: 2000 }, (_, index): Message => {
const body = sampleBodies[index % sampleBodies.length]!
const repeatCount = index % 7 === 0 ? 2 : 1
const repeatedBody = Array.from({ length: repeatCount }, () => body).join('\n\n')
return {
id: `message-${index}`,
author: index % 3 === 0 ? 'Support' : index % 3 === 1 ? 'Customer' : 'Ops',
body: `${repeatedBody}\n\nMessage ${index + 1}`,
time: `${String(8 + (index % 10)).padStart(2, '0')}:${String((index * 7) % 60).padStart(2, '0')}`,
type: index % 3 === 1 ? 'user' : 'agent',
}
})
static const preparedCache = new Map<string, ReturnType<typeof prepare>>()
static function getPreparedMessage(message: Message) {
const key = `${message.id}:${BODY_FONT}:${BODY_LETTER_SPACING}:${message.body}`
const cached = preparedCache.get(key)
if (cached) return cached
const prepared = prepare(message.body, BODY_FONT, {
letterSpacing: BODY_LETTER_SPACING,
whiteSpace: 'pre-wrap',
})
preparedCache.set(key, prepared)
return prepared
}
static function fallbackTextHeight(text: string, width: number) {
const averageCharacterWidth = 7
const charactersPerLine = Math.max(1, Math.floor(width / averageCharacterWidth))
return text.split('\n').reduce((height, paragraph) => {
const lineCount = Math.max(1, Math.ceil(paragraph.length / charactersPerLine))
return height + lineCount * BODY_LINE_HEIGHT
}, 0)
}
static function estimateMessageHeight(message: Message, viewportWidth: number) {
const bubbleWidth = Math.min(BUBBLE_MAX_WIDTH, Math.max(1, viewportWidth - ROW_X_PADDING))
const textWidth = Math.max(1, bubbleWidth - BUBBLE_X_PADDING - BUBBLE_BORDER_WIDTH)
const textHeight =
typeof Intl !== 'undefined' && 'Segmenter' in Intl
? layout(getPreparedMessage(message), textWidth, BODY_LINE_HEIGHT).height
: fallbackTextHeight(message.body, textWidth)
const bodyHeight = Math.max(BODY_LINE_HEIGHT, textHeight)
return Math.ceil(
ROW_Y_PADDING + BUBBLE_Y_PADDING + BUBBLE_BORDER_WIDTH + META_HEIGHT + META_BODY_GAP + bodyHeight,
)
}
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Pretext — @tanstack/marko-virtual</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; padding: 24px; }
h1 { font-size: 24px; margin-bottom: 8px; }
.intro { color: #555; margin-bottom: 16px; font-size: 14px; line-height: 1.5; }
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.toolbar button { padding: 6px 12px; font-size: 13px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer; }
.toolbar button:hover { background: #f3f4f6; }
.stat { font-size: 13px; color: #6b7280; margin-left: auto; }
.list { height: 480px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 6px; background: #fafafa; }
.spacer { position: relative; width: 100%; }
.message-row { position: absolute; top: 0; left: 0; width: 100%; padding: 8px 16px; display: flex; }
.message-row.user { justify-content: flex-end; }
.message-bubble { max-width: 680px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px 14px; background: #fff; font: 14px Arial; }
.message-row.user .message-bubble { background: #eef2ff; }
.message-meta { display: flex; gap: 8px; font-size: 12px; color: #6b7280; height: 18px; margin-bottom: 6px; }
.message-body { font: 14px Arial; line-height: 20px; letter-spacing: 0; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>Pretext</h1>
<p class="intro">
Every row height here is CALCULATED from the text (via @chenglou/pretext canvas
measurement) before the row ever renders — estimateSize returns the true height, so
there is no measureElement, no post-render corrections, and jumping anywhere lands
exactly. When the container width changes or the real font finishes loading, one
v.measure() call drops all cached sizes and everything recomputes.
</p>
// Width lives in a plain holder read by the STABLE estimateSize closure — no need to
// hand the virtualizer a new function per width (React's useCallback dance). The
// ResizeObserver updates the holder and calls v.measure(), which drops every cached
// size; core then re-runs the same estimateSize against the fresh width.
<const/widthState = ({ current: DEFAULT_VIEWPORT_WIDTH })/>
<virtualizer/v
count=MESSAGES.length
estimateSize=(index: number) => estimateMessageHeight(MESSAGES[index]!, widthState.current)
getItemKey=(index: number) => MESSAGES[index]!.id
getScrollElement=(): Element | null => scrollEl() ?? null
overscan=8
/>
<div class="toolbar">
<button data-testid="top" onClick() { v.scrollToIndex(0) }>Top</button>
<button data-testid="middle" onClick() { v.scrollToIndex(MESSAGES.length / 2) }>Middle</button>
<button data-testid="bottom" onClick() { v.scrollToIndex(MESSAGES.length - 1) }>Bottom</button>
<div class="stat" data-testid="stat">${v.virtualItems.length} rendered of ${MESSAGES.length}</div>
</div>
<div/scrollEl class="list" data-testid="list">
<div class="spacer" data-testid="spacer" style=`height: ${v.totalSize}px`>
<for|item| of=v.virtualItems by=(item) => String(item.key)>
<const/message = (MESSAGES[item.index]!)/>
<div
data-index=item.index
class=`message-row ${message.type}`
style=`height: ${item.size}px; transform: translateY(${item.start}px)`
>
<article class="message-bubble">
<div class="message-meta">
<span>${message.author}</span>
<span>${message.time}</span>
</div>
<p class="message-body">${message.body}</p>
</article>
</div>
</for>
</div>
</div>
<const/lifecycleState = ({ cleanup: null as (() => void) | null })/>
<lifecycle
onMount() {
const node = scrollEl()
if (!node) return
const updateWidth = () => {
const next = Math.max(1, Math.round(node.clientWidth))
if (next !== widthState.current) {
widthState.current = next
v.measure() // drop all cached sizes; heights recompute at the new width
}
}
updateWidth()
const observer = new ResizeObserver(updateWidth)
observer.observe(node)
// Recalculate once the real font's metrics are in: canvas measurement done with a
// fallback font would drift from the final rendering.
let cancelled = false
document.fonts.ready.then(() => {
if (cancelled) return
preparedCache.clear()
clearCache()
v.measure()
})
lifecycleState.cleanup = () => {
cancelled = true
observer.disconnect()
}
}
onDestroy() {
lifecycleState.cleanup?.()
}
/>
</body>
</html>