Chat bubble pre-sizing
Open a familiar chat UI, ask a long question, and watch the reply stream in. Often the messages above the reply give a little jump every time a new line wraps. That jump is a chat UI reading its own DOM to find out how tall the growing bubble became, after it became that tall.
Send a message below. The bubble answers token by token. In Pretext mode the reply bubble's height is set before each render — it arrives already the right size, and the other messages never shift. Flip to DOM-measured to watch the history jitter on every wrap.
Mechanism
Both engines share the same markup: a bubble is a fixed-height box with an inner text element. The difference is who decides the height, and when.
Pretext mode: inside the token loop, we call prepare(currentText, font) and then layout(handle, bubbleWidth, lineHeight). That returns the height the bubble will need once the text is painted. We set bubble.style.height to that value, then write the new text into the inner element. The growth animates cleanly because every frame the bubble is already the right size.
DOM-measured mode: we let the inner element render first, then read getBoundingClientRect().height off the DOM and copy it to the bubble's height. Because the read happens after the browser has already re-laid-out the message list, all the earlier messages have already jumped. We also pay for a synchronous layout flush on every token, while Pretext's version pays zero.
The two engines run the same token stream against the same text at the same width. Only the engine differs.
Application
The anti-jitter recipe generalizes to anything that mounts or grows in a list:
- LLM chat UIs during streaming — messages above the reply stay pinned.
- Notification stacks where a new toast slides in at its final height, not at zero.
- Inline editors in a feed that expand as the user types, without nudging the comments below.
- Auto-grow textareas whose height matches the text on the next keystroke, not the previous one.
"Confine yourself to the present. Accept the things to which fate binds you, and love the people with whom fate brings you together, but do so with all your heart."
Marcus Aurelius, Meditations (2nd c. · PD translation 1906)Direct Claude
layout() before inserting the element
"no jitter during streaming"
→
re-prepare() + re-layout() each token; set height before render
"notification lands at its final size"
→
same pattern — compute height, animate from there, mount pre-sized
"auto-grow without the lag-by-one"
→
answer the height from layout(), not from scrollHeight
import { prepare, layout } from '@chenglou/pretext';
// For each incoming token, update the growing bubble's height before
// we write the new text. The whole message list holds still.
async function streamReply(text, bubble, inner) {
let current = '';
for (const tok of tokenize(text)) {
current += tok;
// Phase: analyze + fit. No DOM read.
const handle = prepare(current, FONT);
const { height } = layout(handle, BUBBLE_W, LINE_H);
bubble.style.height = height + 'px';
inner.textContent = current;
await wait(TOKEN_MS);
}
}