AI Patterns

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.

    "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>
      );
    }
    

    A UX spec for this pattern — written for agents implementing or reusing it, not the code.

    # Stream of Thought
    
    ## 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.
    

    Do’s and don’ts

    Do use it for

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

    Don’t use it for

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