Installation
npx shadcn@latest add @pixeldosa/reasoning-streamRequires `cn` at `@/lib/utils` and the `pd-fade-in` keyframe supplied by @pixeldosa/pixeldosa-theme's css block. Show reasoning *summaries* where the provider exposes them; raw private chain-of-thought should not be presented to end users as fact.
Usage
"use client";
import { ReasoningStream } from "@/components/ui/reasoning-stream";
export function Answer({ run }: { run: AgentRun }) {
return (
<>
<ReasoningStream
steps={run.reasoningSummaries}
isStreaming={run.status === "reasoning"}
durationMs={run.reasoningMs}
/>
<p>{run.answer}</p>
</>
);
}
steps is an array rather than a raw string because providers emit reasoning
summaries as discrete parts — deciding where one thought ends is your business, not
the component's.
Features
- It folds away when the answer arrives. This is the whole thesis, not a convenience. Reasoning is scaffolding: useful while the thing is being built, noise once it stands. A panel left expanded after the answer lands makes every answer look like it needs justifying.
- The duration is the useful part. "Thought for 12s" answers was this worth waiting for better than any quantity of reasoning text. It's measured, never estimated.
- Scroll anchoring done properly. It follows new content only while you're at the bottom. The moment you scroll up to read something, it holds your position and offers "Jump to latest" instead of yanking you back.
- Never fake typing. Steps fade in whole. Text that already arrived is never revealed character-by-character, because that misrepresents content the model had already finished producing.
- Animate once, never replay — enforced by React's keyed reconciliation, not a timer.
Variants
Streaming
A ticker, newest at the bottom, older lines masked out above. It reads as a process, which is what it still is.
Found five flagged invoices across the last quarter.
Three share the same billing contact — that's the strongest signal so far.
Checking the invoice table for duplicate payment warnings.
Found five flagged invoices across the last quarter.
Three share the same billing contact — that's the strongest signal so far.
Complete
Collapsed by default. The trace is one click away for anyone who wants it, and out of the way for everyone who doesn't.
Checking the invoice table for duplicate payment warnings.
Found five flagged invoices across the last quarter.
Three share the same billing contact.
They all came from the 14 August import batch.
Expanded
Checking the invoice table for duplicate payment warnings.
Found five flagged invoices across the last quarter.
Three share the same billing contact.
They all came from the 14 August import batch.
The other two are unrelated, so this is one root cause, not five.
Examples
Mapping from the AI SDK, where reasoning is a distinct part type:
const reasoning = message.parts
.filter((part) => part.type === "reasoning")
.map((part) => part.text);
<ReasoningStream steps={reasoning} isStreaming={status === "streaming"} />
Pairing with Agent Presence — the indicator says that it's thinking, this says
what it's thinking:
<AgentPresence state="thinking" form="line" size="sm" />
<ReasoningStream steps={reasoning} isStreaming />
Opening by default for a debugging or evaluation surface, where the trace is the content rather than the scaffolding:
<ReasoningStream steps={trace} durationMs={elapsed} defaultExpanded />
Props
| Prop | Type | Default | Description |
|---|---|---|---|
steps | string[] | — | Required. Oldest first; append as they arrive. |
isStreaming | boolean | false | Shows the ticker and the "Jump to latest" affordance. |
durationMs | number | — | Measured elapsed time, shown on completion. |
label | string | "Thinking" | Verb shown while streaming. |
defaultExpanded | boolean | false |
All other props forward to the wrapping <div>.
Accessibility
- A real disclosure:
aria-expandedandaria-controlswired to the panel, not a custom widget that looks like one. - The trigger's hit area is extended to ~44px while staying visually compact.
- The full trace is reachable by keyboard, and the collapsed summary carries the same duration a sighted user sees.
- Under
prefers-reduced-motionthe step fades and the expand transition are both dropped. Nothing is lost — the information was never carried by the motion.
When not to use
The agent's activity state is Agent Presence. The full historical record across a
whole turn is Chain-of-Thought Timeline. A collapsed "why this?" over an
already-finished value is AI Context Surface. And show reasoning summaries where
the provider exposes them — raw private chain-of-thought shouldn't be presented to end
users as fact.
why it's built this way
Engineering Notes
The component's thesis is the fold-away on completion, not the ticker. Reasoning text is long, low-value-per-token, and usually not what the user wants to read, which creates a bind: showing all of it buries the answer, showing none of it is the black box that destroys trust. Collapsing to a measured 'Thought for 12s' resolves both — the duration answers 'was this worth waiting for' better than any amount of reasoning text, and the disclosure keeps the trace available for anyone who actually wants it. Scroll anchoring is implemented as a correctness requirement rather than a nicety: the component tracks distance-from-end on scroll and only follows new content while the reader is within 24px of the bottom, because auto-scrolling someone who has deliberately scrolled up to read something is the most hostile thing a streaming surface can do. That behaviour came from a competitive scan of a peer library's Message Scroller and now applies as a standing requirement to every streaming surface in this system, not as a component of its own. Each step is its own keyed child component so React's reconciliation owns the entrance animation — a step already on screen keeps its DOM node and can never replay its fade however often the parent re-renders, which is the same mechanism Progressive Reveal uses and which a timer-based approach gets wrong on the first re-render. `steps` is an array rather than a raw accumulating string so the component never has to guess where one thought ends.
how it moves, and when it doesn't
Motion Notes
Steps fade in whole over `--pd-duration-base` with `--pd-ease-decelerate`, exactly once each. They are never typed out character-by-character: the text has already been produced by the time it reaches this component, and animating it as though it were being composed live would misrepresent what actually happened — the same non-negotiable rule Ghost Input, Smart Field, Diff Accept and Progressive Reveal all hold. The expand/collapse uses the house `grid-template-rows` 0fr→1fr transition at `--pd-duration-fast`, so no height measurement or ResizeObserver is needed and the panel animates correctly at any content length. The ticker's top edge is a CSS mask rather than an animated fade, so older lines dissolve out without anything moving. Under `prefers-reduced-motion` the step fades and the expand transition are both dropped; steps appear instantly and the disclosure snaps, and since the information was never carried by the motion, nothing is lost.