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.
Install#
npx shadcn@latest add @pixilui/streaming-textOnce per app, add the registry to 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
- 1
Install the npm packages.
npm install motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"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> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/streaming-text.tsx. Needs motion, clsx, tailwind-merge.
Preview#
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#
"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.
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#
| Prop | Type | Default | Description |
|---|---|---|---|
textrequired | 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 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#
- 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.