{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "streaming-text", "type": "registry:component", "title": "Streaming Text", "description": "Streamed answer with inline sources, actions, and follow-ups.", "registryDependencies": [ "https://www.beautifului.dev/r/foundation.json" ], "files": [ { "path": "components/primitives/StreamingText.tsx", "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAMING TEXT\n * Words resolve out of blur, inline citations appear in\n * context, then actions and follow-up prompts become usable.\n * ───────────────────────────────────────────────────────── */\n\nconst WORD_MS = 55;\nconst HOLD_MS = 3400;\n\n/* one streamed word, or a `cite` placeholder that renders an inline source chip */\nexport type StreamingToken = { text: string; cite?: boolean };\n\nconst TOKENS: StreamingToken[] = [\n ...\"Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points.\"\n .split(\" \")\n .map((text) => ({ text })),\n { text: \"\", cite: true },\n ...\"Stone-fruit flavors are trending in the same range.\"\n .split(\" \")\n .map((text) => ({ text })),\n];\n\nconst FOLLOW_UPS = [\n \"Which flavors sell best in winter\",\n \"Compare gelato and soft serve margins\",\n];\n\nconst SOURCE_IMAGES = {\n scoop:\n \"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\",\n trends:\n \"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\",\n market:\n \"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\",\n};\n\n/* one cited source rendered as an inline chip and in the sources list */\nexport type StreamingSource = { name: string; domain: string; href: string; image: string };\n\nconst SOURCES: StreamingSource[] = [\n { name: \"Scoop Data\", domain: \"scoopdata.io\", href: \"https://scoopdata.io/\", image: SOURCE_IMAGES.scoop },\n { name: \"Trends Index\", domain: \"trends.google.com\", href: \"https://trends.google.com/trends/\", image: SOURCE_IMAGES.trends },\n { name: \"Market Basket\", domain: \"marketbasket.io\", href: \"https://marketbasket.io/\", image: SOURCE_IMAGES.market },\n];\n\nfunction sourceImage(source: StreamingSource) {\n return source.image;\n}\n\nfunction SourceChip({ source }: { source?: StreamingSource }) {\n if (!source) return null;\n return (\n \n \"\"\n {source.domain}\n \n );\n}\n\nconst ACTION_ICONS: React.ReactNode[] = [\n ,\n ,\n ,\n ,\n];\n\nexport type StreamingLabels = {\n /** label on the collapsed sources toggle */\n sources: string;\n /** heading above the follow-up prompts */\n followUps: string;\n};\n\nconst DEFAULT_LABELS: StreamingLabels = {\n sources: \"10 sources\",\n followUps: \"Follow-ups\",\n};\n\nexport default function StreamingText({\n content = TOKENS,\n sources = SOURCES,\n followUps = FOLLOW_UPS,\n labels,\n loop = true,\n fill = false,\n onDone,\n onFollowUp,\n}: {\n variant?: string;\n /** the streamed tokens; `cite` tokens render an inline source chip */\n content?: StreamingToken[];\n /** cited sources shown in the chip, avatar stack, and expanded list */\n sources?: StreamingSource[];\n /** follow-up prompt suggestions shown once the stream completes */\n followUps?: string[];\n /** prominent copy strings */\n labels?: Partial;\n /** restart the stream after a hold; turn off when embedding in a real thread */\n loop?: boolean;\n /** fill the parent width instead of the gallery's fixed measure */\n fill?: boolean;\n onDone?: () => void;\n /** fired when a follow-up prompt is chosen */\n onFollowUp?: (text: string, index: number) => void;\n} = {}) {\n const l = { ...DEFAULT_LABELS, ...labels };\n const [count, setCount] = useState(0);\n const [sourcesOpen, setSourcesOpen] = useState(false);\n const done = count >= content.length;\n\n useEffect(() => {\n if (done && !loop) {\n onDone?.();\n return;\n }\n const t = setTimeout(\n () => setCount((c) => (c >= content.length ? 0 : c + 1)),\n done ? HOLD_MS : WORD_MS,\n );\n return () => clearTimeout(t);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [count, done, loop]);\n\n return (\n
\n

\n {content.slice(0, count).map((token, i) =>\n token.cite ? (\n \n ) : (\n \n {token.text}{\" \"}\n \n ),\n )}\n {!done && (\n \n )}\n

\n\n {/* action icons row */}\n \n {ACTION_ICONS.map((icon, i) => (\n \n \n {icon}\n \n \n ))}\n setSourcesOpen((current) => !current)}\n 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\"\n >\n \n {sources.map((source) => (\n \n ))}\n \n {l.sources}\n \n
\n\n \n
\n
\n {sources.map((source) => (\n \n \"\"\n {source.name}\n {source.domain}\n \n ))}\n
\n
\n \n\n {/* follow-ups */}\n \n

{l.followUps}

\n
\n {followUps.map((text, i) => (\n onFollowUp?.(text, i)}\n className=\"-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line\n px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors\n duration-100 hover:bg-hover-2\"\n style={\n done\n ? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }\n : { opacity: 0 }\n }\n >\n \n \n \n \n {text}\n \n ))}\n
\n \n \n );\n}\n", "type": "registry:component", "target": "components/primitives/StreamingText.tsx" } ] }