Streaming Text
AI & AgentsStreamed answer with inline sources, actions, and follow-ups.
Interactive Live Preview
components/ui/streaming-text.tsxTypeScript · React 19
1"use client";23import { useEffect, useState } from "react";45/* ─────────────────────────────────────────────────────────6 * STREAMING TEXT7 * Words resolve out of blur, inline citations appear in8 * context, then actions and follow-up prompts become usable.9 * ───────────────────────────────────────────────────────── */1011const WORD_MS = 55;12const HOLD_MS = 3400;1314type Token = { text: string; cite?: boolean };1516const 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];2526const FOLLOW_UPS = [27 "Which flavors sell best in winter",28 "Compare gelato and soft serve margins",29];3031const 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};3940const 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];4546function sourceImage(source: (typeof SOURCES)[number]) {47 return source.image;48}4950function SourceChip() {51 const source = SOURCES[0];52 return (53 <a54 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-hairline59 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}6768const 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];7475export 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;9091 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-deps102 }, [count, done, loop]);103104 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 <span118 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>123124 {/* action icons row */}125 <div126 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 <button131 key={i}132 type="button"133 aria-label="Action"134 className="flex size-6 items-center justify-center rounded-[6px] text-ink-3135 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 <button143 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 <img151 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>161162 <div163 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 <a174 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>188189 {/* follow-ups */}190 <div191 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 <button198 key={text}199 className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line200 px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors201 duration-100 hover:bg-hover-2"202 style={203 done204 ? { 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
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | "default" | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |