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.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/streaming-text

Once per app, add the registry to components.json:

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

Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/streaming-text.json

Adds components/pixilui/streaming-text.tsx. Needs motion, clsx, tailwind-merge.

Preview#

State
Chunks
Speed
Theme
Accent

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
"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#

Use it when

  • 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.

Use something else when

  • 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.
Example
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>
  );
}

API#

PropTypeDefaultDescription
textrequiredstringEverything received so far. Pass the growing string on every chunk; only the new words animate.
streamingbooleanfalseMore text is on its way: shows the caret and holds back a word that has not finished arriving.
caretbooleantrueShow a caret at the end while streaming.
speednumber50Milliseconds between words as they reveal. A large chunk speeds up until the reveal is back within about 1.5s of the stream.
pauseOnHoverbooleanfalsePause the reveal while a mouse rests on the text, so people can read; it catches up on leave.
inlineCodebooleantrueRender text between backticks as inline code.
onRevealComplete() => voidCalled once every word has revealed after streaming ends.

Also takes 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#

Also in the markdown page and llms.txt
  1. 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.
  2. Set streaming to true while chunks arrive and false when the stream closes; the caret drops once the last word is shown.
  3. Text between backticks renders as inline code; turn it off with inlineCode={false}.
  4. Use pauseOnHover on long answers so a reader can stop the text under their mouse.
  5. onRevealComplete fires once the last word is on screen, useful for showing follow-up actions after the answer, not before.