"use client";
import * as React from "react";
import { motion } from "motion/react";
import { Check, Copy, Globe, ThumbsDown, ThumbsUp } from "lucide-react";
export type StreamSegment = { type: "text"; content: string } | { type: "source"; label: string };
export interface StreamingTextProps {
segments: StreamSegment[];
/** Milliseconds between each revealed character. */
speed?: number;
followUps?: string[];
onComplete?: () => void;
className?: string;
}
type Token = { type: "char"; value: string } | { type: "source"; value: string };
function tokenize(segments: StreamSegment[]): Token[] {
const tokens: Token[] = [];
for (const segment of segments) {
if (segment.type === "text") {
for (const char of segment.content) tokens.push({ type: "char", value: char });
} else {
tokens.push({ type: "source", value: segment.label });
}
}
return tokens;
}
export function StreamingText({ segments, speed = 18, followUps, onComplete, className }: StreamingTextProps) {
const tokens = React.useMemo(() => tokenize(segments), [segments]);
const [count, setCount] = React.useState(0);
const done = count >= tokens.length;
React.useEffect(() => {
if (count >= tokens.length) return;
const id = window.setTimeout(() => setCount((c) => c + 1), speed);
return () => window.clearTimeout(id);
}, [count, tokens.length, speed]);
React.useEffect(() => {
if (done) onComplete?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [done]);
const nodes: React.ReactNode[] = [];
let buffer = "";
let key = 0;
for (let i = 0; i < count; i++) {
const token = tokens[i];
if (token.type === "char") {
buffer += token.value;
} else {
if (buffer) {
nodes.push(<React.Fragment key={key++}>{buffer}</React.Fragment>);
buffer = "";
}
nodes.push(<SourceChip key={key++} label={token.value} />);
}
}
if (buffer) nodes.push(<React.Fragment key={key++}>{buffer}</React.Fragment>);
const plainText = segments.map((s) => (s.type === "text" ? s.content : "")).join("");
return (
<div className={className}>
<p className="text-sm leading-relaxed text-foreground/90">
{nodes}
{!done && <BlinkingCursor />}
</p>
{done && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="mt-3 flex flex-wrap items-center gap-1.5"
>
<ActionButtons text={plainText} />
{followUps?.length ? (
<>
<span className="mx-1 h-4 w-px bg-border" />
{followUps.map((label) => (
<button
key={label}
type="button"
className="rounded-full border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{label}
</button>
))}
</>
) : null}
</motion.div>
)}
</div>
);
}
function BlinkingCursor() {
return (
<motion.span
aria-hidden
className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] bg-foreground"
animate={{ opacity: [1, 1, 0, 0] }}
transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1], ease: "linear" }}
/>
);
}
function SourceChip({ label }: { label: string }) {
return (
<span className="mx-1 inline-flex -translate-y-px items-center gap-1 rounded-full border bg-muted px-2 py-0.5 align-middle text-xs">
<span className="flex size-3.5 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
<Globe className="size-2.5" />
</span>
<span className="font-mono text-muted-foreground">{label}</span>
</span>
);
}
function ActionButtons({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false);
async function handleCopy() {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
return (
<div className="flex items-center gap-0.5 text-muted-foreground">
<button
type="button"
onClick={handleCopy}
aria-label="Copy"
className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
<button
type="button"
aria-label="Good response"
className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
>
<ThumbsUp className="size-3.5" />
</button>
<button
type="button"
aria-label="Bad response"
className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
>
<ThumbsDown className="size-3.5" />
</button>
</div>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Streaming Text
## Summary
An answer that reveals itself progressively, as if it's being generated live, with inline source citations that appear at their exact position in the text — followed by quick actions and follow-up suggestions once the stream finishes.
## When to use
- Displaying an LLM-generated answer as it's actually produced (or a scripted approximation of that for a demo).
- Answers that cite specific sources inline, where the citation should feel attached to the exact claim it supports rather than listed separately at the end.
## When not to use
- Content the user already has in full, such as re-rendering a past message when a conversation reloads — show it fully formed, don't replay the reveal animation.
- Very long documents (multiple paragraphs+), where a full character-by-character reveal just delays reading. Consider revealing by paragraph/chunk instead.
- Attaching follow-up suggestion chips to content that isn't actually actionable — don't add them out of habit.
## Anatomy
- Streamed body text.
- Inline source chip(s): a small pill with an icon and a domain/source name, appearing inline mid-sentence.
- A cursor at the current write position, visible only while streaming.
- Post-stream action row: copy, thumbs up/down.
- Post-stream follow-up suggestion chips.
## Behavior
- Text reveals at a constant, fast pace — slow enough to read as "live", fast enough that it never feels like a gimmick or an artificial delay.
- A source chip appears as a whole unit at its position; it does not itself type in character by character.
- The cursor disappears the instant streaming completes.
- The action row and follow-up chips appear only after the stream finishes, never during — so they don't compete for attention while the user is still reading.
- Follow-up chips are optional next steps the user can click, not required actions.
## Content guidelines
- Citations are short (a domain or short source name), not a full URL.
- Follow-up suggestions are phrased as something the user would say next, in their voice ("Show trend chart"), not the agent's voice ("I can show a trend chart").
## Accessibility
- The streaming region should be an `aria-live="polite"` region; announcing every character is disruptive, so throttle or announce only on completion.
- Respect `prefers-reduced-motion` by rendering the full text immediately instead of animating the character reveal.
- The cursor is decorative — mark it `aria-hidden`.
## Related patterns
- **Thinking Loader** — Use it before this one, while the agent is working and there is no answer text yet.
- **Expandable Trace** — Use it as well above the answer as the audit trail of how it was reached.
- **Partial Response** — Use it instead once the stream ends early, so the cut-short answer gets a reason and a way to continue.
Do’s and don’ts
Do use it for
- Displaying an LLM-generated answer as it's actually produced (or a scripted approximation of that for a demo).
- Answers that cite specific sources inline, where the citation should feel attached to the exact claim it supports rather than listed separately at the end.
Don’t use it for
- Content the user already has in full, such as re-rendering a past message when a conversation reloads — show it fully formed, don't replay the reveal animation.
- Very long documents (multiple paragraphs+), where a full character-by-character reveal just delays reading. Consider revealing by paragraph/chunk instead.
- Attaching follow-up suggestion chips to content that isn't actually actionable — don't add them out of habit.
Related patterns
Thinking Loader
Use it before this one, while the agent is working and there is no answer text yet.
Expandable Trace
Use it as well above the answer as the audit trail of how it was reached.
Partial Response
Use it instead once the stream ends early, so the cut-short answer gets a reason and a way to continue.