Streaming Text

AI & Agents

Streamed answer with inline sources, actions, and follow-ups.

Interactive Live Preview
components/ui/streaming-text.tsxTypeScript · React 19
1"use client";
2
3import { useEffect, useState } from "react";
4
5/* ─────────────────────────────────────────────────────────
6 * STREAMING TEXT
7 * Words resolve out of blur, inline citations appear in
8 * context, then actions and follow-up prompts become usable.
9 * ───────────────────────────────────────────────────────── */
10
11const WORD_MS = 55;
12const HOLD_MS = 3400;
13
14type Token = { text: string; cite?: boolean };
15
16const TOKENS: Token[] = [
17 ..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points."
18 .split(" ")
19 .map((text) => ({ text })),
20 { text: "", cite: true },
21 ..."Stone-fruit flavors are trending in the same range."
22 .split(" ")
23 .map((text) => ({ text })),
24];
25
26const FOLLOW_UPS = [
27 "Which flavors sell best in winter",
28 "Compare gelato and soft serve margins",
29];
30
31const SOURCE_IMAGES = {
32 scoop:
33 "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
34 trends:
35 "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
36 market:
37 "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
38};
39
40const SOURCES = [
41 { name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop },
42 { name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends },
43 { name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market },
44];
45
46function sourceImage(source: (typeof SOURCES)[number]) {
47 return source.image;
48}
49
50function SourceChip() {
51 const source = SOURCES[0];
52 return (
53 <a
54 href={source.href}
55 target="_blank"
56 rel="noreferrer"
57 className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
58 bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
59 transition-colors duration-150 hover:bg-hover hover:text-ink"
60 style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
61 >
62 <img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
63 <span>{source.domain}</span>
64 </a>
65 );
66}
67
68const ACTION_ICONS: React.ReactNode[] = [
69 <g key="copy"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></g>,
70 <path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
71 <path key="up" d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z" />,
72 <path key="down" d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z" />,
73];
74
75export default function StreamingText({
76 loop = true,
77 fill = false,
78 onDone,
79}: {
80 variant?: string;
81 /** restart the stream after a hold; turn off when embedding in a real thread */
82 loop?: boolean;
83 /** fill the parent width instead of the gallery's fixed measure */
84 fill?: boolean;
85 onDone?: () => void;
86}) {
87 const [count, setCount] = useState(0);
88 const [sourcesOpen, setSourcesOpen] = useState(false);
89 const done = count >= TOKENS.length;
90
91 useEffect(() => {
92 if (done && !loop) {
93 onDone?.();
94 return;
95 }
96 const t = setTimeout(
97 () => setCount((c) => (c >= TOKENS.length ? 0 : c + 1)),
98 done ? HOLD_MS : WORD_MS,
99 );
100 return () => clearTimeout(t);
101 // eslint-disable-next-line react-hooks/exhaustive-deps
102 }, [count, done, loop]);
103
104 return (
105 <div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
106 <p className="text-[13px] leading-relaxed text-ink">
107 {TOKENS.slice(0, count).map((token, i) =>
108 token.cite ? (
109 <SourceChip key={i} />
110 ) : (
111 <span key={i} className="inline">
112 {token.text}{" "}
113 </span>
114 ),
115 )}
116 {!done && (
117 <span
118 className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
119 style={{ animation: "fade-in 150ms ease-out both" }}
120 />
121 )}
122 </p>
123
124 {/* action icons row */}
125 <div
126 className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
127 style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
128 >
129 {ACTION_ICONS.map((icon, i) => (
130 <button
131 key={i}
132 type="button"
133 aria-label="Action"
134 className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
135 transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
136 >
137 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
138 {icon}
139 </svg>
140 </button>
141 ))}
142 <button
143 type="button"
144 aria-expanded={sourcesOpen}
145 onClick={() => setSourcesOpen((current) => !current)}
146 className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
147 >
148 <span className="flex -space-x-1">
149 {SOURCES.map((source) => (
150 <img
151 key={source.domain}
152 src={sourceImage(source)}
153 alt=""
154 className="source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]"
155 />
156 ))}
157 </span>
158 <span className="text-[12px] text-ink-2">10 sources</span>
159 </button>
160 </div>
161
162 <div
163 className="grid transition-[grid-template-rows,opacity] duration-300"
164 style={{
165 gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
166 opacity: done && sourcesOpen ? 1 : 0,
167 transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
168 }}
169 >
170 <div className="overflow-hidden">
171 <div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
172 {SOURCES.map((source) => (
173 <a
174 key={source.domain}
175 href={source.href}
176 target="_blank"
177 rel="noreferrer"
178 className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
179 >
180 <img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
181 <span className="animated-underline">{source.name}</span>
182 <span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
183 </a>
184 ))}
185 </div>
186 </div>
187 </div>
188
189 {/* follow-ups */}
190 <div
191 className="mt-2.5 transition-opacity duration-400"
192 style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
193 >
194 <p className="text-[12px] font-medium text-ink-2">Follow-ups</p>
195 <div className="mt-0.5 flex flex-col">
196 {FOLLOW_UPS.map((text, i) => (
197 <button
198 key={text}
199 className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
200 px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
201 duration-100 hover:bg-hover-2"
202 style={
203 done
204 ? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
205 : { opacity: 0 }
206 }
207 >
208 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
209 <path d="M9 10l-5 5 5 5" />
210 <path d="M20 4v7a4 4 0 0 1-4 4H4" />
211 </svg>
212 {text}
213 </button>
214 ))}
215 </div>
216 </div>
217 </div>
218 );
219}
220

Props & Specification

PropTypeDefaultDescription
variantstring&quot;default&quot;Visual state and layout presentation mode.
classNamestring""Optional Tailwind CSS wrapper class overrides.

Related Components in AI & Agents