{"slug":"stream-of-thought","category":"traces","job":"oversee","added":"2026-11-07T22:34Z","title":"Stream of Thought","description":"A live, timed header over reasoning that streams in as a step timeline, then folds into a \"Thought for Ns\" trace when the answer starts.","url":"https://ai-patterns.app/patterns/traces/stream-of-thought","markdown":"https://ai-patterns.app/patterns/traces/stream-of-thought.md","json":"https://ai-patterns.app/api/patterns/traces/stream-of-thought","seenIn":[{"team":"chatgpt","source":"https://help.openai.com/en/articles/11909943-gpt-5-5-in-chatgpt"},{"team":"lovable","source":"https://docs.lovable.dev/features/agent-mode"},{"team":"Perplexity","source":"https://www.perplexity.ai/help-center/en/articles/10352903-what-is-pro-search"}],"spec":"# Stream of Thought\n\n## Summary\nThe model's reasoning streams inline as a timeline of short steps, each a headline and a few lines of prose, under a live header. The header shows the title of the current step sliding into place and a ticking timer, like Thinking Loader. When the answer starts, the timeline collapses into a \"Thought for Ns\" toggle that expands into the full trace, like Expandable Trace. One component covers the wait, the live reasoning and the record afterwards.\n\n## When to use\n- Reasoning models that think for several seconds or more before answering. Watching the thoughts arrive makes the wait feel shorter and shows that work is happening.\n- When the reasoning arrives live as prose (or a live summary of it) that falls into a few named beats.\n- When people may want to check how the model reached its conclusion, but most only want the answer.\n\n## When not to use\n- When there is nothing real to stream. Don't invent placeholder thoughts to fill a wait; use Thinking Loader.\n- When the steps are discrete tool actions with results (search, read, edit) and no prose. Expandable Trace or Task List say that more plainly.\n- When the raw reasoning may contain content you can't show users (policy text, system prompt details). Stream a summary, or don't show it at all.\n\n## Anatomy\n- **Header toggle:** an icon, a label and a chevron; the whole row is the toggle.\n  - While thinking: a brain icon, the current step's title shimmering and sliding in from below, and an elapsed-seconds counter on the right whose digits roll as they change.\n  - When done: a sparkle icon and \"Thought for Ns\". The counter is gone.\n- **Timeline:** one step per thought, joined by a thin vertical line. Each step has a mark, a short headline and muted body text.\n- **Step mark:** a pulsing dot on the step that is still streaming; a check on finished steps.\n- **Live window:** while thinking, the timeline is capped to a few lines, with a fade at the top edge so older steps dissolve upward instead of being cut off.\n- **The answer:** below and outside the component, in full-contrast text.\n\n## Behavior\n- Open by default while thinking. New text appends at the bottom and the window stays scrolled to the newest line. If the person scrolls up inside it, stop auto-scrolling until they return to the bottom.\n- When a new step starts, its title replaces the header label, and the previous step's dot turns into a check.\n- The timer counts whole seconds from when thinking started. It freezes when thinking ends, and that number becomes \"Thought for Ns\". Pass the server's duration instead when you have it.\n- When thinking ends, the last dot turns into a check and the timeline collapses with a short height animation. The answer then fades in below.\n- After completion, the expanded timeline shows every step in full, with no height cap and no fade.\n- If the person toggles the header at any point, keep their choice. Don't auto-collapse something they just opened, or reopen something they closed.\n- Text appears in small chunks (words or tokens), following the model's rhythm, not letter by letter at a fixed speed.\n- With reduced motion, drop the shimmer and the height animation; the text still streams and the labels still change.\n\n## Content guidelines\n- Step titles are short gerund phrases (\"Weighing the options\"), at most about five words. They double as the live header label, so they must fit on one line.\n- Body text can be first person and informal. It's thinking, not the answer, and should read that way.\n- Use the real elapsed time (\"Thought for 9s\"). Round to whole seconds, and switch to minutes past 60s (\"Thought for 2m\").\n- The answer must not depend on the reasoning: it repeats any conclusion it needs, so nobody has to expand the trace to understand it.\n\n## Accessibility\n- The header is a real `button` with `aria-expanded` and `aria-controls` pointing at the timeline.\n- Announce state through a visually hidden `role=\"status\"` (\"Thinking\", then \"Thought for N seconds\"). Hide the sliding label and the ticking timer from assistive tech, and don't make the streaming body a live region: announcing every token or second is unusable.\n- Mark the timeline `aria-busy=\"true\"` while it streams, and make the live window focusable so keyboard users can scroll it.\n- Use an ordered list for the steps. Shimmer and pulse are decorative; labels must read at sufficient contrast without them.\n\n## Related patterns\n- **Thinking Loader** — Use it instead when there is no reasoning text to show, just the live header.\n- **Expandable Trace** — Use it instead when the reasoning is already complete and doesn't need to stream.\n- **Streaming Text** — Use it as well below for the answer itself as it streams in.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Brain, Check, ChevronDown, Sparkles } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface Thought {\n  /** A short headline for this beat of reasoning, e.g. \"Comparing the two options\". */\n  title: string;\n  /** The reasoning text itself. Pass the partial string while it streams. */\n  body: string;\n}\n\nexport interface StreamOfThoughtProps {\n  /** Thoughts streamed so far. The last one is still growing while `thinking` is true. */\n  thoughts: Thought[];\n  /** True while the model is still reasoning. */\n  thinking: boolean;\n  /** Overrides the measured duration shown once thinking finishes (\"Thought for 12s\"). */\n  durationSeconds?: number;\n  /** Max height of the live window while streaming, in px. */\n  liveHeight?: number;\n  className?: string;\n}\n\nexport function StreamOfThought({\n  thoughts,\n  thinking,\n  durationSeconds,\n  liveHeight = 148,\n  className,\n}: StreamOfThoughtProps) {\n  const reduceMotion = useReducedMotion();\n  const bodyId = React.useId();\n  const elapsed = useElapsedSeconds(thinking);\n  const seconds = durationSeconds ?? elapsed;\n\n  // `null` = follow the default (open while thinking, closed when done).\n  // Once the person toggles, their choice wins.\n  const [userOpen, setUserOpen] = React.useState<boolean | null>(null);\n  const open = userOpen ?? thinking;\n\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const pinned = React.useRef(true);\n\n  // Keep the live window scrolled to the newest line, unless the person\n  // scrolled up to re-read something.\n  React.useLayoutEffect(() => {\n    const el = scrollRef.current;\n    if (!el || !thinking || !pinned.current) return;\n    el.scrollTop = el.scrollHeight;\n  }, [thoughts, thinking, open]);\n\n  function onScroll() {\n    const el = scrollRef.current;\n    if (!el) return;\n    pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 8;\n  }\n\n  const current = thoughts.at(-1)?.title ?? \"Thinking\";\n\n  return (\n    <div className={cn(\"text-sm\", className)}>\n      <button\n        type=\"button\"\n        onClick={() => setUserOpen(!open)}\n        aria-expanded={open}\n        aria-controls={bodyId}\n        className=\"flex w-full items-center gap-2 rounded-md py-1 text-left text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        {thinking ? (\n          <Brain className=\"size-3.5 shrink-0\" aria-hidden />\n        ) : (\n          <Sparkles className=\"size-3.5 shrink-0\" aria-hidden />\n        )}\n        {/* Announces \"Thinking\" once, then \"Thought for Ns\" — never the stream itself. */}\n        <span className=\"sr-only\" role=\"status\">\n          {thinking ? \"Thinking\" : `Thought for ${seconds} seconds`}\n        </span>\n        <span aria-hidden className=\"min-w-0 truncate\">\n          {thinking ? (\n            <SlidingLabel label={current} shimmer={!reduceMotion} />\n          ) : (\n            <span className=\"text-foreground\">Thought for {seconds}s</span>\n          )}\n        </span>\n        <span aria-hidden className=\"ml-auto flex shrink-0 items-center gap-2\">\n          {thinking && (\n            <span className=\"flex font-mono tabular-nums\">\n              <SlidingNumber value={String(seconds)} />s\n            </span>\n          )}\n          <ChevronDown\n            className={cn(\"size-3.5 transition-transform duration-200\", open && \"rotate-180\")}\n          />\n        </span>\n      </button>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            id={bodyId}\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={{ duration: reduceMotion ? 0 : 0.25, ease: [0.2, 0, 0, 1] }}\n            className=\"overflow-hidden\"\n          >\n            <div\n              ref={scrollRef}\n              onScroll={onScroll}\n              aria-busy={thinking}\n              aria-label=\"Reasoning\"\n              tabIndex={thinking ? 0 : undefined}\n              className={cn(\n                \"mt-2 focus-visible:outline-none\",\n                thinking && \"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n              )}\n              style={\n                thinking\n                  ? {\n                      maxHeight: liveHeight,\n                      maskImage: \"linear-gradient(to bottom, transparent, black 28px)\",\n                      WebkitMaskImage: \"linear-gradient(to bottom, transparent, black 28px)\",\n                    }\n                  : undefined\n              }\n            >\n              <ol>\n                {thoughts.map((thought, i) => {\n                  const last = i === thoughts.length - 1;\n                  const live = thinking && last;\n                  return (\n                    <motion.li\n                      key={i}\n                      initial={reduceMotion ? false : { opacity: 0, y: 4 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      transition={{ duration: 0.2 }}\n                      className=\"flex gap-3\"\n                    >\n                      <div className=\"flex flex-col items-center pt-0.5\">\n                        <StepMark live={live} />\n                        {!last && <span className=\"my-1 w-px flex-1 bg-border\" />}\n                      </div>\n                      <div className={cn(\"min-w-0\", !last && \"pb-3\")}>\n                        <p className=\"text-xs font-medium text-foreground/90\">{thought.title}</p>\n                        <p className=\"text-xs leading-relaxed text-muted-foreground\">\n                          {thought.body}\n                        </p>\n                      </div>\n                    </motion.li>\n                  );\n                })}\n              </ol>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/** A pulsing dot while the step streams; a check once it's done. */\nfunction StepMark({ live }: { live: boolean }) {\n  return (\n    <span className=\"flex size-4 shrink-0 items-center justify-center rounded-full bg-muted\">\n      <AnimatePresence mode=\"wait\" initial={false}>\n        {live ? (\n          <motion.span\n            key=\"live\"\n            className=\"size-1.5 rounded-full bg-muted-foreground\"\n            animate={{ opacity: [0.35, 1, 0.35] }}\n            transition={{ repeat: Infinity, duration: 1.2 }}\n          />\n        ) : (\n          <motion.span\n            key=\"done\"\n            initial={{ scale: 0.5, opacity: 0 }}\n            animate={{ scale: 1, opacity: 1 }}\n            transition={{ duration: 0.15 }}\n          >\n            <Check className=\"size-2.5 text-muted-foreground\" />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Counts whole seconds while `running`, and freezes when it stops. */\nfunction useElapsedSeconds(running: boolean) {\n  const [elapsed, setElapsed] = React.useState(0);\n\n  React.useEffect(() => {\n    if (!running) return;\n    const start = Date.now();\n    const id = window.setInterval(() => {\n      setElapsed(Math.floor((Date.now() - start) / 1000));\n    }, 250);\n    return () => window.clearInterval(id);\n  }, [running]);\n\n  return elapsed;\n}\n\n/** The current thought's title slides in from below and shimmers. */\nfunction SlidingLabel({ label, shimmer }: { label: string; shimmer: boolean }) {\n  return (\n    <span className=\"relative inline-flex h-[1.4em] items-center overflow-hidden\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={label}\n          className={cn(\"inline-block whitespace-nowrap\", shimmer && \"bg-clip-text text-transparent\")}\n          style={\n            shimmer\n              ? {\n                  backgroundImage:\n                    \"linear-gradient(90deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)\",\n                  backgroundSize: \"200% 100%\",\n                }\n              : undefined\n          }\n          initial={{ y: 10, opacity: 0, backgroundPositionX: \"150%\" }}\n          animate={{ y: 0, opacity: 1, backgroundPositionX: \"-50%\" }}\n          exit={{ y: -10, opacity: 0 }}\n          transition={{\n            y: { duration: 0.15, ease: \"easeOut\" },\n            opacity: { duration: 0.15, ease: \"easeOut\" },\n            backgroundPositionX: { repeat: Infinity, duration: 1.4, ease: \"linear\", repeatDelay: 0.6 },\n          }}\n        >\n          {label}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Each digit slides up and out when it changes. */\nfunction SlidingNumber({ value }: { value: string }) {\n  return (\n    <span className=\"inline-flex\">\n      {value.split(\"\").map((char, i) => (\n        <span key={i} className=\"relative inline-block h-[1.2em] w-[0.62em] overflow-hidden\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={`${i}-${char}`}\n              initial={{ y: 10, opacity: 0 }}\n              animate={{ y: 0, opacity: 1 }}\n              exit={{ y: -10, opacity: 0 }}\n              transition={{ duration: 0.25, ease: \"easeOut\" }}\n              className=\"absolute inset-0 flex items-center justify-center\"\n            >\n              {char}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\n    </span>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}