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.