How to stream LLM text in React without the layout jumping

· 4 min read

Keep one growing string in state, append every chunk to it, and render only whole words, revealed at a steady pace of about one word every 50 ms. Hold back a half-arrived word, keep the container's width fixed, and put a caret at the end while text is still coming. That stops the reflow and the stutter.

Why streamed text jumps#

A model does not send words. It sends tokens, and your server forwards them in chunks whose size depends on the network, the provider and your own buffering. One chunk holds two characters, the next holds forty. If you render each chunk the moment it lands, three things go wrong:

  • Half-words wrap and unwrap. "renew" arrives, wraps to the next line, then "als" arrives and the line breaks again in a different place.
  • Bursts read as noise. A forty-character chunk appears all at once after a pause, so the reading rhythm is fast, stop, fast.
  • Everything below moves. Each new line pushes the composer, the sources and the page itself down while the person is trying to read.

The fix is to separate what has arrived from what is shown.

Separate arrival from reveal#

Treat the network as a buffer and the screen as a reader:

  1. Accumulate. Append each chunk to one string. Never render chunks as separate elements; you lose word boundaries.
  2. Split into words, keep the tail. Everything up to the last whitespace is complete. The rest might be half a word, so keep it hidden until the next chunk or the end of the stream.
  3. Reveal on a clock. Show complete words one at a time, about every 50 ms. When a large chunk arrives, speed up until the reveal is back within a second or two of the network, so a long answer never falls far behind.
  4. Animate only what is new. Words already on screen never move or re-render. A new word can rise a few pixels out of a soft blur; anything more is a show, not text.
  5. End cleanly. When the stream closes, reveal the held tail, remove the caret, and only then show actions such as Copy or Regenerate.

Keep the box steady#

  • Give the answer a stable width (its column), so only height can change.
  • Let height grow downward. If the view is pinned to the bottom, keep it pinned; if the person scrolled up to read, stop following and show a "Jump to latest" control instead of pulling them down.
  • Do not parse partial markdown into block elements mid-stream. A half-open code fence turns the rest of the answer into code for a moment. Render inline code as it closes and leave block parsing for complete paragraphs.

Accessibility#

Screen readers should not announce every word. Mark the region with aria-busy="true" while streaming and announce once when it completes, or put a short status ("Answer ready") in a polite live region. With prefers-reduced-motion, show words without the rise and keep the pace.

With PixilUI#

StreamingText does the accumulate, split, hold and reveal steps. You pass the growing string and whether more is coming.

tsx
"use client";

import { useState } from "react";
import { StreamingText } from "@/components/pixilui/streaming-text";

export function Answer({ question }: { question: string }) {
  const [text, setText] = useState("");
  const [streaming, setStreaming] = useState(false);

  async function ask() {
    setText("");
    setStreaming(true);
    const response = await fetch("/api/answer", { method: "POST", body: JSON.stringify({ question }) });
    const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      setText((previous) => previous + value); // the whole string so far, every time
    }
    setStreaming(false);
  }

  return (
    <div>
      <button onClick={ask}>Ask</button>
      <StreamingText text={text} streaming={streaming} speed={50} />
    </div>
  );
}

Install it with npx shadcn@latest add @pixilui/streaming-text. The same pattern works with the Vercel AI SDK or any SDK that gives you text deltas: keep appending to one string and pass it in.

When not to use this#

  • Short replies. If answers are a sentence or two and arrive in under a second, render them whole. A reveal adds delay with nothing to smooth.
  • Code and tables. Streaming a long code block word by word is slow to read. Show a thinking state, then render code blocks once they are complete.
  • Logs and raw output. A console that prints thousands of lines needs an append-only, virtualized list with follow and pause, not a reading-pace reveal.
  • When you need exact timing. If your product measures time to first token, show the raw stream in a debug view; the paced reveal hides it on purpose.