text
Streaming Text
A streamed answer with inline sources, actions, and follow-ups.
"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
- Often follows a Thinking Loader / Expandable Trace pair: loader while working, trace for the audit trail, this component for the actual answer.