# Streaming text

Renders a model's answer as it streams: only new words rise into place at reading speed, a caret holds the end, and half-arrived words wait until they are whole.

Tier: free. Type: pattern. Category: ai. Tags: chat, streaming, motion.

## Install

```bash
npx shadcn@latest add @pixilui/streaming-text
```

One-time setup: add the registry to `components.json`:

```json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}
```

Files: `components/pixilui/streaming-text.tsx`. npm: `motion`.

## Preview

Live preview: https://pixilui.com/components/streaming-text

Knobs:

- `state`: `play`, `done`
- `chunks`: `tokens`, `one big chunk`
- `speed`: `50`, `30`, `80`

Signature: Uneven chunks arrive but the words rise one by one at reading pace, half-words wait their turn, and the caret leaves when the last word lands.

## Code

`components/pixilui/streaming-text.tsx`

```tsx
"use client";

import {
  Fragment,
  forwardRef,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
  type ComponentPropsWithoutRef,
  type PointerEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { motionTokens, reducedTransition } from "./motion-tokens";

const noSubscription = () => () => {};

/**
 * useReducedMotion, held at false until hydration finishes. The server cannot know the
 * preference, so reading it on the first client render would change the markup and break
 * hydration; the reduced branch takes over on the next render instead.
 */
function useReducedMotionAfterHydration() {
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const reduced = useReducedMotion();
  return hydrated && (reduced ?? false);
}

export type StreamingTextProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
  /** Everything received so far. Pass the growing string on every chunk; only the new words animate. */
  text: string;
  /** More text is on its way: shows the caret and holds back a word that has not finished arriving. */
  streaming?: boolean;
  /** Show a caret at the end while streaming. */
  caret?: boolean;
  /** Milliseconds between words as they reveal. A large chunk speeds up until the reveal is back within about 1.5s of the stream. */
  speed?: number;
  /** Pause the reveal while a mouse rests on the text, so people can read; it catches up on leave. */
  pauseOnHover?: boolean;
  /** Render text between backticks as inline code. */
  inlineCode?: boolean;
  /** Called once every word has revealed after streaming ends. */
  onRevealComplete?: () => void;
};

type Token = {
  /** The word, without the whitespace after it. */
  word: string;
  /** Whitespace after the word (may hold newlines). Rendered as plain text, never animated. */
  space: string;
  code: boolean;
};

type Reveal = {
  tokens: Token[];
  /** Tokens on screen. */
  released: number;
  /** Tokens that have finished animating and render as plain text. */
  settled: number;
  /** The tokens released in the last tick, spread across it. */
  batchFrom: number;
  batchSize: number;
  /** Words per tick while catching up. Held steady until the backlog is back inside the window. */
  rate: number;
};

/** A large chunk speeds the reveal up until it is back within this many milliseconds of words. */
const MAX_LAG_MS = 1500;

function tokenize(text: string, inlineCode: boolean): Token[] {
  const tokens: Token[] = [];
  const parts = inlineCode ? text.split("`") : [text];
  parts.forEach((part, index) => {
    if (!part) return;
    const code = inlineCode && index % 2 === 1;
    const lead = /^\s+/.exec(part);
    if (lead) tokens.push({ word: "", space: lead[0], code });
    for (const match of part.slice(lead ? lead[0].length : 0).matchAll(/(\S+)(\s*)/g)) {
      tokens.push({ word: match[1]!, space: match[2]!, code });
    }
  });
  return tokens;
}

/** How many leading tokens two token lists share (words and code only; trailing space may grow). */
function sharedPrefix(a: Token[], b: Token[]) {
  const max = Math.min(a.length, b.length);
  let i = 0;
  while (i < max && a[i]!.word === b[i]!.word && a[i]!.code === b[i]!.code) i++;
  return i;
}

/** Settled tokens as a few plain runs: one text node per run, however long the text gets. */
function runs(tokens: Token[]) {
  const out: { text: string; code: boolean }[] = [];
  for (const token of tokens) {
    const last = out[out.length - 1];
    if (last && last.code === token.code) last.text += token.word + token.space;
    else out.push({ text: token.word + token.space, code: token.code });
  }
  return out;
}

const codeClass = "rounded-control bg-surface-muted px-1 py-px font-mono";
const codeStyle = { fontSize: "0.9em" };

export const StreamingText = forwardRef<HTMLDivElement, StreamingTextProps>(function StreamingText(
  {
    text,
    streaming = false,
    caret = true,
    speed = 50,
    pauseOnHover = false,
    inlineCode = true,
    onRevealComplete,
    className,
    onPointerEnter,
    onPointerLeave,
    ...rest
  },
  ref,
) {
  const reduced = useReducedMotionAfterHydration();
  const tokens = useMemo(() => tokenize(text, inlineCode), [text, inlineCode]);
  const last = tokens[tokens.length - 1];
  // A word without whitespace after it may still be arriving ("Searc" then "hed"): hold it back.
  const available = streaming && last && last.space === "" ? tokens.length - 1 : tokens.length;

  const [state, setState] = useState<Reveal>(() => ({ tokens, released: 0, settled: 0, batchFrom: 0, batchSize: 0, rate: 1 }));
  const [paused, setPaused] = useState(false);

  // Append-only diff, during render: keep what still matches, rewind past anything that changed.
  let reveal = state;
  if (state.tokens !== tokens) {
    const keep = sharedPrefix(state.tokens, tokens);
    reveal = {
      tokens,
      released: Math.min(state.released, keep),
      settled: Math.min(state.settled, keep),
      batchFrom: state.batchFrom,
      batchSize: state.batchSize,
      rate: state.rate,
    };
    setState(reveal);
  }

  const released = reduced ? available : Math.min(reveal.released, tokens.length);
  const settled = reduced ? available : Math.min(reveal.settled, released);
  const hasBacklog = !reduced && reveal.released < available;

  const availableRef = useRef(available);
  const generation = useRef(0);
  useLayoutEffect(() => {
    availableRef.current = available;
  }, [available]);
  useLayoutEffect(() => {
    generation.current += 1;
  }, [tokens]);

  // The clock: one tick per `speed` ms while there is a backlog. New chunks never restart it.
  useEffect(() => {
    if (!hasBacklog || paused) return;
    const id = setTimeout(() => {
      setState((prev) => {
        const backlog = availableRef.current - prev.released;
        if (backlog <= 0) return prev;
        // Inside the window: one word per tick. Beyond it: a steady rate that drains the excess in about one window.
        const inWindow = Math.max(1, Math.floor(MAX_LAG_MS / speed));
        const rate = backlog <= inWindow ? 1 : Math.max(prev.rate, Math.ceil(backlog / inWindow));
        const size = Math.min(backlog, rate);
        return { ...prev, released: prev.released + size, batchFrom: prev.released, batchSize: size, rate };
      });
    }, speed);
    return () => clearTimeout(id);
  }, [hasBacklog, paused, reveal.released, speed]);

  // Words settle into plain text once their rise has finished, so the DOM stays a few nodes deep.
  const settleTimers = useRef(new Set<ReturnType<typeof setTimeout>>());
  useEffect(() => {
    if (reduced || reveal.released <= reveal.settled) return;
    const target = reveal.released;
    const gen = generation.current;
    const timers = settleTimers.current;
    const id = setTimeout(() => {
      timers.delete(id);
      if (gen !== generation.current) return;
      setState((prev) => ({ ...prev, settled: Math.max(prev.settled, Math.min(target, prev.released)) }));
    }, motionTokens.duration.fast * 1000 + speed);
    timers.add(id);
  }, [reduced, reveal.released, reveal.settled, speed]);
  useEffect(() => {
    const timers = settleTimers.current;
    return () => timers.forEach(clearTimeout);
  }, []);

  const complete = !streaming && released >= tokens.length;
  const completeRef = useRef(complete);
  useEffect(() => {
    if (complete && !completeRef.current) onRevealComplete?.();
    completeRef.current = complete;
  }, [complete, onRevealComplete]);

  const plain = useMemo(() => runs(tokens.slice(0, settled)), [tokens, settled]);
  const tail = tokens.slice(settled, released);

  const handleEnter = (event: PointerEvent<HTMLDivElement>) => {
    onPointerEnter?.(event);
    if (pauseOnHover && event.pointerType === "mouse") setPaused(true);
  };
  const handleLeave = (event: PointerEvent<HTMLDivElement>) => {
    onPointerLeave?.(event);
    setPaused(false);
  };

  const spread = reveal.batchSize > 1 ? speed / reveal.batchSize / 1000 : 0;

  return (
    <div
      ref={ref}
      data-status={complete ? "done" : "streaming"}
      data-paused={paused || undefined}
      aria-busy={!complete}
      className={cn("min-w-0 whitespace-pre-wrap break-words font-body text-16 text-pretty text-foreground", className)}
      onPointerEnter={handleEnter}
      onPointerLeave={handleLeave}
      {...rest}
    >
      {plain.map((run, index) =>
        run.code ? (
          <code key={index} className={codeClass} style={codeStyle}>
            {run.text}
          </code>
        ) : (
          <Fragment key={index}>{run.text}</Fragment>
        ),
      )}
      {tail.map((token, offset) => {
        const index = settled + offset;
        const inBatch = index - reveal.batchFrom;
        const delay = inBatch > 0 && inBatch < reveal.batchSize ? inBatch * spread : 0;
        return (
          <Fragment key={index}>
            {token.word ? (
              <motion.span
                data-slot="streaming-text-word"
                className={cn("inline-block", token.code && codeClass)}
                style={token.code ? codeStyle : undefined}
                initial={{ opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }}
                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                transition={{ duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay }}
              >
                {token.word}
              </motion.span>
            ) : null}
            {token.space}
          </Fragment>
        );
      })}
      <AnimatePresence initial={false}>
        {caret && !complete ? (
          <motion.span
            key="caret"
            aria-hidden="true"
            data-slot="streaming-text-caret"
            className="ms-0.5 inline-block h-[1.1em] w-0.5 translate-y-[0.2em] rounded-pill bg-foreground/70"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={reduced ? reducedTransition : { duration: motionTokens.duration.quick, ease: motionTokens.ease.swap }}
          />
        ) : null}
      </AnimatePresence>
    </div>
  );
});
```

## Usage

When to use:

- Showing a model's reply token by token in a chat, a summary panel or an inline suggestion.
- Any text that arrives in chunks of uneven size and should read as one calm stream.

When not to use:

- Markdown with headings, lists and code blocks: render it with your markdown renderer and stream into that.
- Static copy that is already complete: render it as text.
- Status while the model has not started answering: use thinking-state.

```tsx
import { useEffect, useState, useSyncExternalStore } from "react";
import { useReducedMotion } from "motion/react";
import { RotateCcw } from "lucide-react";
import { Button } from "@/components/pixilui/button";
import { StreamingText } from "@/components/pixilui/streaming-text";

const noSubscription = () => () => {};

/**
 * useReducedMotion, held at false until hydration finishes. The server cannot know the
 * preference, so reading it on the first client render would change the markup and break
 * hydration; the reduced branch takes over on the next render instead.
 */
function useReducedMotionAfterHydration() {
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const reduced = useReducedMotion();
  return hydrated && (reduced ?? false);
}

// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
  state: ["play", "done"],
  chunks: ["tokens", "one big chunk"],
  speed: [50, 30, 80],
} as const;

const ANSWER =
  "Checkout errors started at 09:42 UTC, four minutes after deploy `4f3a9c1` shipped new retry logic in `payments-client`. " +
  "Every failed call to Stripe retried three times with no backoff, so request volume doubled and 1,318 checkouts timed out " +
  "before Priya Nair rolled the deploy back at 09:58.\n\n" +
  "About $41,300 in carts were abandoned, mostly from 212 merchants on the EU cluster. I opened ENG-4127 to add jittered " +
  "backoff and a test that fails on more than one retry per second.";

// Uneven chunk sizes (in characters) and gaps, the way a model streams: words often split mid-way.
const SIZES = [7, 3, 12, 5, 18, 2, 9, 4, 14, 6, 11, 8];
const GAPS = [70, 45, 110, 60, 90, 40, 130];

function chunkPlan(bigChunk: boolean) {
  if (bigChunk) {
    const cut = ANSWER.indexOf("\n\n") + 2;
    return [
      { at: 400, end: cut },
      { at: 2600, end: ANSWER.length },
    ];
  }
  const plan: { at: number; end: number }[] = [];
  let end = 0;
  let at = 400;
  for (let i = 0; end < ANSWER.length; i++) {
    end = Math.min(ANSWER.length, end + SIZES[i % SIZES.length]!);
    at += GAPS[i % GAPS.length]!;
    plan.push({ at, end });
  }
  return plan;
}

export default function StreamingTextDemo({ state = "play", chunks = "tokens", speed = 50 }: KnobValues<typeof knobs>) {
  const reduced = useReducedMotionAfterHydration();
  const [run, setRun] = useState(0);
  const [received, setReceived] = useState(0);
  const [finished, setFinished] = useState(false);
  const [revealed, setRevealed] = useState(false);

  const playing = state === "play" && !reduced;

  // A knob change starts the stream again.
  const combo = `${state}-${chunks}-${speed}`;
  const [seen, setSeen] = useState(combo);
  if (seen !== combo) {
    setSeen(combo);
    setReceived(0);
    setFinished(false);
    setRevealed(false);
  }

  useEffect(() => {
    if (!playing) return;
    const plan = chunkPlan(chunks === "one big chunk");
    const timers = plan.map(({ at, end }, index) =>
      setTimeout(() => {
        setReceived(end);
        if (index === plan.length - 1) setFinished(true);
      }, at),
    );
    return () => timers.forEach(clearTimeout);
  }, [playing, chunks, run]);

  const text = playing ? ANSWER.slice(0, received) : ANSWER;
  const streaming = playing && !finished;

  return (
    <div className="flex w-full min-w-0 max-w-lg flex-col gap-5">
      <p className="self-end rounded-panel bg-surface px-4 py-2.5 font-body text-14 text-foreground hairline">Why did checkout errors spike this morning?</p>
      <StreamingText
        key={`${state}-${chunks}-${speed}-${run}`}
        text={text}
        streaming={streaming}
        speed={speed}
        pauseOnHover
        onRevealComplete={() => setRevealed(true)}
        className="min-h-48"
      />
      <div className="flex h-control-sm justify-center">
        {playing && revealed ? (
          <Button
            variant="ghost"
            size="sm"
            icon={<RotateCcw strokeWidth={1.5} />}
            onClick={() => {
              setReceived(0);
              setFinished(false);
              setRevealed(false);
              setRun((n) => n + 1);
            }}
          >
            Replay
          </Button>
        ) : null}
      </div>
    </div>
  );
}
```

## Examples

None.

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` |  | Everything received so far. Pass the growing string on every chunk; only the new words animate. |
| `streaming` | `boolean` | `false` | More text is on its way: shows the caret and holds back a word that has not finished arriving. |
| `caret` | `boolean` | `true` | Show a caret at the end while streaming. |
| `speed` | `number` | `50` | Milliseconds between words as they reveal. A large chunk speeds up until the reveal is back within about 1.5s of the stream. |
| `pauseOnHover` | `boolean` | `false` | Pause the reveal while a mouse rests on the text, so people can read; it catches up on leave. |
| `inlineCode` | `boolean` | `true` | Render text between backticks as inline code. |
| `onRevealComplete` | `() => void` |  | Called once every word has revealed after streaming ends. |

Also accepts every native `<div>` attribute, and a ref to it.

## Keyboard

- `Tab`: Nothing to focus; the text is read as it is, and links or code inside it keep their own behaviour.

## Accessibility

- The root sets aria-busy while text is still arriving, so assistive tech can wait for the whole answer.
- It is not a live region on its own: put it inside your message list's role="log" so new messages are announced once, not word by word.
- Words are kept whole in the DOM and the caret is aria-hidden, so the text reads normally at any moment.

## Motion

- Only new words animate: each rises 0.3em out of a 4px blur in 250ms on the enter easing.
- Words reveal about 50ms apart (speed). A large chunk speeds up until the reveal is back within about 1.5s of the stream.
- A word whose end has not arrived yet is held back, so a word never animates in two halves.
- The caret fades out in 150ms when the stream ends and every word is shown.
- Reduced motion: chunks appear instantly, no rise, no blur.

## Responsive

- Wraps like normal text and keeps newlines (white-space: pre-wrap); long words and URLs break inside the line.
- Inherits the container's width; default size is text-16, override with className.

## Performance

- Text is diffed per chunk (append-only); words that finished animating collapse back into a few text nodes, so a long answer stays a handful of DOM nodes.
- Animation runs on transform, opacity and filter only, so no layout is read per word.
- One timer drives the reveal; new chunks never restart it.

## Notes for AI

- Pass the whole text received so far on every chunk, not the delta. If the text changes in the middle (a regenerate), the component rewinds to the shared prefix.
- Set streaming to true while chunks arrive and false when the stream closes; the caret drops once the last word is shown.
- Text between backticks renders as inline code; turn it off with inlineCode={false}.
- Use pauseOnHover on long answers so a reader can stop the text under their mouse.
- onRevealComplete fires once the last word is on screen, useful for showing follow-up actions after the answer, not before.

## Related

- [Thinking state](https://pixilui.com/components/thinking-state/markdown): A quiet line that says what the agent is doing while it works, with a live timer, steps that unfold, and a fold into "Thought for 12s" when it is done.
- [Tool call row](https://pixilui.com/components/tool-call-row/markdown): One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line.
- [Run timeline](https://pixilui.com/components/run-timeline/markdown): The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval.
