{"slug":"streaming-text","category":"text","job":"oversee","added":"2026-09-11T20:22Z","title":"Streaming Text","description":"A streamed answer with inline sources, actions, and follow-ups.","url":"https://ai-patterns.app/patterns/text/streaming-text","markdown":"https://ai-patterns.app/patterns/text/streaming-text.md","json":"https://ai-patterns.app/api/patterns/text/streaming-text","seenIn":[{"team":"chatgpt","source":"https://help.openai.com/en/articles/9237897-chatgpt-search"}],"spec":"# Streaming Text\n\n## Summary\nAn 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.\n\n## When to use\n- Displaying an LLM-generated answer as it's actually produced (or a scripted approximation of that for a demo).\n- 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.\n\n## When not to use\n- 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.\n- Very long documents (multiple paragraphs+), where a full character-by-character reveal just delays reading. Consider revealing by paragraph/chunk instead.\n- Attaching follow-up suggestion chips to content that isn't actually actionable — don't add them out of habit.\n\n## Anatomy\n- Streamed body text.\n- Inline source chip(s): a small pill with an icon and a domain/source name, appearing inline mid-sentence.\n- A cursor at the current write position, visible only while streaming.\n- Post-stream action row: copy, thumbs up/down.\n- Post-stream follow-up suggestion chips.\n\n## Behavior\n- 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.\n- A source chip appears as a whole unit at its position; it does not itself type in character by character.\n- The cursor disappears the instant streaming completes.\n- 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.\n- Follow-up chips are optional next steps the user can click, not required actions.\n\n## Content guidelines\n- Citations are short (a domain or short source name), not a full URL.\n- 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\").\n\n## Accessibility\n- The streaming region should be an `aria-live=\"polite\"` region; announcing every character is disruptive, so throttle or announce only on completion.\n- Respect `prefers-reduced-motion` by rendering the full text immediately instead of animating the character reveal.\n- The cursor is decorative — mark it `aria-hidden`.\n\n## Related patterns\n- **Thinking Loader** — Use it before this one, while the agent is working and there is no answer text yet.\n- **Expandable Trace** — Use it as well above the answer as the audit trail of how it was reached.\n- **Partial Response** — Use it instead once the stream ends early, so the cut-short answer gets a reason and a way to continue.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\nimport { Check, Copy, Globe, ThumbsDown, ThumbsUp } from \"lucide-react\";\n\nexport type StreamSegment = { type: \"text\"; content: string } | { type: \"source\"; label: string };\n\nexport interface StreamingTextProps {\n  segments: StreamSegment[];\n  /** Milliseconds between each revealed character. */\n  speed?: number;\n  followUps?: string[];\n  onComplete?: () => void;\n  className?: string;\n}\n\ntype Token = { type: \"char\"; value: string } | { type: \"source\"; value: string };\n\nfunction tokenize(segments: StreamSegment[]): Token[] {\n  const tokens: Token[] = [];\n  for (const segment of segments) {\n    if (segment.type === \"text\") {\n      for (const char of segment.content) tokens.push({ type: \"char\", value: char });\n    } else {\n      tokens.push({ type: \"source\", value: segment.label });\n    }\n  }\n  return tokens;\n}\n\nexport function StreamingText({ segments, speed = 18, followUps, onComplete, className }: StreamingTextProps) {\n  const tokens = React.useMemo(() => tokenize(segments), [segments]);\n  const [count, setCount] = React.useState(0);\n  const done = count >= tokens.length;\n\n  React.useEffect(() => {\n    if (count >= tokens.length) return;\n    const id = window.setTimeout(() => setCount((c) => c + 1), speed);\n    return () => window.clearTimeout(id);\n  }, [count, tokens.length, speed]);\n\n  React.useEffect(() => {\n    if (done) onComplete?.();\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [done]);\n\n  const nodes: React.ReactNode[] = [];\n  let buffer = \"\";\n  let key = 0;\n  for (let i = 0; i < count; i++) {\n    const token = tokens[i];\n    if (token.type === \"char\") {\n      buffer += token.value;\n    } else {\n      if (buffer) {\n        nodes.push(<React.Fragment key={key++}>{buffer}</React.Fragment>);\n        buffer = \"\";\n      }\n      nodes.push(<SourceChip key={key++} label={token.value} />);\n    }\n  }\n  if (buffer) nodes.push(<React.Fragment key={key++}>{buffer}</React.Fragment>);\n\n  const plainText = segments.map((s) => (s.type === \"text\" ? s.content : \"\")).join(\"\");\n\n  return (\n    <div className={className}>\n      <p className=\"text-sm leading-relaxed text-foreground/90\">\n        {nodes}\n        {!done && <BlinkingCursor />}\n      </p>\n      {done && (\n        <motion.div\n          initial={{ opacity: 0, y: 4 }}\n          animate={{ opacity: 1, y: 0 }}\n          transition={{ duration: 0.2 }}\n          className=\"mt-3 flex flex-wrap items-center gap-1.5\"\n        >\n          <ActionButtons text={plainText} />\n          {followUps?.length ? (\n            <>\n              <span className=\"mx-1 h-4 w-px bg-border\" />\n              {followUps.map((label) => (\n                <button\n                  key={label}\n                  type=\"button\"\n                  className=\"rounded-full border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground\"\n                >\n                  {label}\n                </button>\n              ))}\n            </>\n          ) : null}\n        </motion.div>\n      )}\n    </div>\n  );\n}\n\nfunction BlinkingCursor() {\n  return (\n    <motion.span\n      aria-hidden\n      className=\"ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] bg-foreground\"\n      animate={{ opacity: [1, 1, 0, 0] }}\n      transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1], ease: \"linear\" }}\n    />\n  );\n}\n\nfunction SourceChip({ label }: { label: string }) {\n  return (\n    <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\">\n      <span className=\"flex size-3.5 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400\">\n        <Globe className=\"size-2.5\" />\n      </span>\n      <span className=\"font-mono text-muted-foreground\">{label}</span>\n    </span>\n  );\n}\n\nfunction ActionButtons({ text }: { text: string }) {\n  const [copied, setCopied] = React.useState(false);\n\n  async function handleCopy() {\n    await navigator.clipboard.writeText(text);\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1500);\n  }\n\n  return (\n    <div className=\"flex items-center gap-0.5 text-muted-foreground\">\n      <button\n        type=\"button\"\n        onClick={handleCopy}\n        aria-label=\"Copy\"\n        className=\"rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground\"\n      >\n        {copied ? <Check className=\"size-3.5\" /> : <Copy className=\"size-3.5\" />}\n      </button>\n      <button\n        type=\"button\"\n        aria-label=\"Good response\"\n        className=\"rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground\"\n      >\n        <ThumbsUp className=\"size-3.5\" />\n      </button>\n      <button\n        type=\"button\"\n        aria-label=\"Bad response\"\n        className=\"rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground\"\n      >\n        <ThumbsDown className=\"size-3.5\" />\n      </button>\n    </div>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":[]}