# Stream of Thought

> 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.

- Category: traces
- Job: Oversee — Show progress and reasoning while the AI works, and let people approve, pause or stop it.
- Slug: `stream-of-thought`
- HTML: https://ai-patterns.app/patterns/traces/stream-of-thought
- JSON: https://ai-patterns.app/api/patterns/traces/stream-of-thought
- Seen in: [chatgpt](https://help.openai.com/en/articles/11909943-gpt-5-5-in-chatgpt), [lovable](https://docs.lovable.dev/features/agent-mode), [Perplexity](https://www.perplexity.ai/help-center/en/articles/10352903-what-is-pro-search)

## Summary
The 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.

## When to use
- 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.
- When the reasoning arrives live as prose (or a live summary of it) that falls into a few named beats.
- When people may want to check how the model reached its conclusion, but most only want the answer.

## When not to use
- When there is nothing real to stream. Don't invent placeholder thoughts to fill a wait; use Thinking Loader.
- When the steps are discrete tool actions with results (search, read, edit) and no prose. Expandable Trace or Task List say that more plainly.
- 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.

## Anatomy
- **Header toggle:** an icon, a label and a chevron; the whole row is the toggle.
  - 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.
  - When done: a sparkle icon and "Thought for Ns". The counter is gone.
- **Timeline:** one step per thought, joined by a thin vertical line. Each step has a mark, a short headline and muted body text.
- **Step mark:** a pulsing dot on the step that is still streaming; a check on finished steps.
- **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.
- **The answer:** below and outside the component, in full-contrast text.

## Behavior
- 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.
- When a new step starts, its title replaces the header label, and the previous step's dot turns into a check.
- 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.
- 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.
- After completion, the expanded timeline shows every step in full, with no height cap and no fade.
- 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.
- Text appears in small chunks (words or tokens), following the model's rhythm, not letter by letter at a fixed speed.
- With reduced motion, drop the shimmer and the height animation; the text still streams and the labels still change.

## Content guidelines
- 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.
- Body text can be first person and informal. It's thinking, not the answer, and should read that way.
- Use the real elapsed time ("Thought for 9s"). Round to whole seconds, and switch to minutes past 60s ("Thought for 2m").
- The answer must not depend on the reasoning: it repeats any conclusion it needs, so nobody has to expand the trace to understand it.

## Accessibility
- The header is a real `button` with `aria-expanded` and `aria-controls` pointing at the timeline.
- 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.
- Mark the timeline `aria-busy="true"` while it streams, and make the live window focusable so keyboard users can scroll it.
- Use an ordered list for the steps. Shimmer and pulse are decorative; labels must read at sufficient contrast without them.

## Related patterns
- **Thinking Loader** — Use it instead when there is no reasoning text to show, just the live header.
- **Expandable Trace** — Use it instead when the reasoning is already complete and doesn't need to stream.
- **Streaming Text** — Use it as well below for the answer itself as it streams in.

## Implementation

Reference implementation (React, TypeScript, Tailwind v4, Motion, Radix). Keep every rule from the spec above; if the host project has its own design system, re-express the markup and motion in it rather than copying these classes.

npm dependencies: `lucide-react`, `motion`

Local imports to provide: `@/lib/utils` (`cn` is the usual `clsx` + `tailwind-merge` helper).

```tsx title="component.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Brain, Check, ChevronDown, Sparkles } from "lucide-react";

import { cn } from "@/lib/utils";

export interface Thought {
  /** A short headline for this beat of reasoning, e.g. "Comparing the two options". */
  title: string;
  /** The reasoning text itself. Pass the partial string while it streams. */
  body: string;
}

export interface StreamOfThoughtProps {
  /** Thoughts streamed so far. The last one is still growing while `thinking` is true. */
  thoughts: Thought[];
  /** True while the model is still reasoning. */
  thinking: boolean;
  /** Overrides the measured duration shown once thinking finishes ("Thought for 12s"). */
  durationSeconds?: number;
  /** Max height of the live window while streaming, in px. */
  liveHeight?: number;
  className?: string;
}

export function StreamOfThought({
  thoughts,
  thinking,
  durationSeconds,
  liveHeight = 148,
  className,
}: StreamOfThoughtProps) {
  const reduceMotion = useReducedMotion();
  const bodyId = React.useId();
  const elapsed = useElapsedSeconds(thinking);
  const seconds = durationSeconds ?? elapsed;

  // `null` = follow the default (open while thinking, closed when done).
  // Once the person toggles, their choice wins.
  const [userOpen, setUserOpen] = React.useState<boolean | null>(null);
  const open = userOpen ?? thinking;

  const scrollRef = React.useRef<HTMLDivElement>(null);
  const pinned = React.useRef(true);

  // Keep the live window scrolled to the newest line, unless the person
  // scrolled up to re-read something.
  React.useLayoutEffect(() => {
    const el = scrollRef.current;
    if (!el || !thinking || !pinned.current) return;
    el.scrollTop = el.scrollHeight;
  }, [thoughts, thinking, open]);

  function onScroll() {
    const el = scrollRef.current;
    if (!el) return;
    pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 8;
  }

  const current = thoughts.at(-1)?.title ?? "Thinking";

  return (
    <div className={cn("text-sm", className)}>
      <button
        type="button"
        onClick={() => setUserOpen(!open)}
        aria-expanded={open}
        aria-controls={bodyId}
        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"
      >
        {thinking ? (
          <Brain className="size-3.5 shrink-0" aria-hidden />
        ) : (
          <Sparkles className="size-3.5 shrink-0" aria-hidden />
        )}
        {/* Announces "Thinking" once, then "Thought for Ns" — never the stream itself. */}
        <span className="sr-only" role="status">
          {thinking ? "Thinking" : `Thought for ${seconds} seconds`}
        </span>
        <span aria-hidden className="min-w-0 truncate">
          {thinking ? (
            <SlidingLabel label={current} shimmer={!reduceMotion} />
          ) : (
            <span className="text-foreground">Thought for {seconds}s</span>
          )}
        </span>
        <span aria-hidden className="ml-auto flex shrink-0 items-center gap-2">
          {thinking && (
            <span className="flex font-mono tabular-nums">
              <SlidingNumber value={String(seconds)} />s
            </span>
          )}
          <ChevronDown
            className={cn("size-3.5 transition-transform duration-200", open && "rotate-180")}
          />
        </span>
      </button>

      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            id={bodyId}
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: reduceMotion ? 0 : 0.25, ease: [0.2, 0, 0, 1] }}
            className="overflow-hidden"
          >
            <div
              ref={scrollRef}
              onScroll={onScroll}
              aria-busy={thinking}
              aria-label="Reasoning"
              tabIndex={thinking ? 0 : undefined}
              className={cn(
                "mt-2 focus-visible:outline-none",
                thinking && "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
              )}
              style={
                thinking
                  ? {
                      maxHeight: liveHeight,
                      maskImage: "linear-gradient(to bottom, transparent, black 28px)",
                      WebkitMaskImage: "linear-gradient(to bottom, transparent, black 28px)",
                    }
                  : undefined
              }
            >
              <ol>
                {thoughts.map((thought, i) => {
                  const last = i === thoughts.length - 1;
                  const live = thinking && last;
                  return (
                    <motion.li
                      key={i}
                      initial={reduceMotion ? false : { opacity: 0, y: 4 }}
                      animate={{ opacity: 1, y: 0 }}
                      transition={{ duration: 0.2 }}
                      className="flex gap-3"
                    >
                      <div className="flex flex-col items-center pt-0.5">
                        <StepMark live={live} />
                        {!last && <span className="my-1 w-px flex-1 bg-border" />}
                      </div>
                      <div className={cn("min-w-0", !last && "pb-3")}>
                        <p className="text-xs font-medium text-foreground/90">{thought.title}</p>
                        <p className="text-xs leading-relaxed text-muted-foreground">
                          {thought.body}
                        </p>
                      </div>
                    </motion.li>
                  );
                })}
              </ol>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

/** A pulsing dot while the step streams; a check once it's done. */
function StepMark({ live }: { live: boolean }) {
  return (
    <span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-muted">
      <AnimatePresence mode="wait" initial={false}>
        {live ? (
          <motion.span
            key="live"
            className="size-1.5 rounded-full bg-muted-foreground"
            animate={{ opacity: [0.35, 1, 0.35] }}
            transition={{ repeat: Infinity, duration: 1.2 }}
          />
        ) : (
          <motion.span
            key="done"
            initial={{ scale: 0.5, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            transition={{ duration: 0.15 }}
          >
            <Check className="size-2.5 text-muted-foreground" />
          </motion.span>
        )}
      </AnimatePresence>
    </span>
  );
}

/** Counts whole seconds while `running`, and freezes when it stops. */
function useElapsedSeconds(running: boolean) {
  const [elapsed, setElapsed] = React.useState(0);

  React.useEffect(() => {
    if (!running) return;
    const start = Date.now();
    const id = window.setInterval(() => {
      setElapsed(Math.floor((Date.now() - start) / 1000));
    }, 250);
    return () => window.clearInterval(id);
  }, [running]);

  return elapsed;
}

/** The current thought's title slides in from below and shimmers. */
function SlidingLabel({ label, shimmer }: { label: string; shimmer: boolean }) {
  return (
    <span className="relative inline-flex h-[1.4em] items-center overflow-hidden">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={label}
          className={cn("inline-block whitespace-nowrap", shimmer && "bg-clip-text text-transparent")}
          style={
            shimmer
              ? {
                  backgroundImage:
                    "linear-gradient(90deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)",
                  backgroundSize: "200% 100%",
                }
              : undefined
          }
          initial={{ y: 10, opacity: 0, backgroundPositionX: "150%" }}
          animate={{ y: 0, opacity: 1, backgroundPositionX: "-50%" }}
          exit={{ y: -10, opacity: 0 }}
          transition={{
            y: { duration: 0.15, ease: "easeOut" },
            opacity: { duration: 0.15, ease: "easeOut" },
            backgroundPositionX: { repeat: Infinity, duration: 1.4, ease: "linear", repeatDelay: 0.6 },
          }}
        >
          {label}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

/** Each digit slides up and out when it changes. */
function SlidingNumber({ value }: { value: string }) {
  return (
    <span className="inline-flex">
      {value.split("").map((char, i) => (
        <span key={i} className="relative inline-block h-[1.2em] w-[0.62em] overflow-hidden">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={`${i}-${char}`}
              initial={{ y: 10, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: -10, opacity: 0 }}
              transition={{ duration: 0.25, ease: "easeOut" }}
              className="absolute inset-0 flex items-center justify-center"
            >
              {char}
            </motion.span>
          </AnimatePresence>
        </span>
      ))}
    </span>
  );
}
```
