Reasoning
A collapsible panel for AI reasoning that opens while the model streams and closes when it finishes.
Installation

pnpm dlx shadcn@latest add @cortexcn/reasoningThe reasoning text renders as markdown with Streamdown. Add its styles and let Tailwind see its classes in your global CSS. Adjust the paths if your CSS file is not in app/, or if your node_modules sits higher up in a monorepo.
@import "streamdown/styles.css";
@source "../node_modules/streamdown/dist/*.js";
@source "../node_modules/@streamdown/code/dist/*.js";
@source "../node_modules/@streamdown/mermaid/dist/*.js";If your model writes math, also import katex/dist/katex.min.css once in your app.
Usage
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/reasoning";<Reasoning isStreaming={isStreaming}>
<ReasoningTrigger />
<ReasoningContent>{reasoningText}</ReasoningContent>
</Reasoning>With the AI SDK, render one Reasoning for each reasoning part of a message. Pass isStreaming while that part is still arriving.
{message.parts.map((part, index) =>
part.type === "reasoning" ? (
<Reasoning
isStreaming={status === "streaming" && index === message.parts.length - 1}
key={index}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
) : null,
)}Behavior
- The panel opens when
isStreamingturns on, and closes itself one second after it turns off. It closes itself only once, so a reader who opens it again keeps it open. - It times the stream and shows "Thought for 5 seconds" when it ends. Pass
durationto show a time you measured yourself. - Set
defaultOpen={false}to keep the panel shut while the model streams.
Examples
Finished
A closed panel for a message that has already streamed, with the time it took.
"use client";import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/reasoning";const refundReasoning = `The order shipped 12 days ago, which is inside the 30 day refund window.The customer says the box is unopened, and unopened items qualify for a **full refund**. No restocking fee applies, so the reply can confirm the refund and send the return label.`;export function ReasoningFinished() { return ( <Reasoning className="w-full max-w-xl" defaultOpen={false} duration={6}> <ReasoningTrigger /> <ReasoningContent>{refundReasoning}</ReasoningContent> </Reasoning> );}Custom message
getThinkingMessage replaces the trigger text, both while streaming and after.
"use client";import { SparklesIcon } from "lucide-react";import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/reasoning";import { Shimmer } from "@/components/shimmer";const planReasoning = `1. Group the tasks by owner.2. Move anything blocked to the end of the week.3. Leave Friday afternoon free for review.`;export function ReasoningCustomMessage() { return ( <Reasoning className="w-full max-w-xl" defaultOpen={false} duration={3}> <ReasoningTrigger getThinkingMessage={(isStreaming, duration) => isStreaming ? ( <Shimmer as="span">Planning your week...</Shimmer> ) : ( <span className="flex items-center gap-1.5"> <SparklesIcon className="size-3.5" /> Planned your week in {duration}s </span> ) } /> <ReasoningContent>{planReasoning}</ReasoningContent> </Reasoning> );}Streaming
"use client";import { RotateCcwIcon } from "lucide-react";import { useEffect, useState } from "react";import { Button } from "@/components/button";import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/reasoning";const dashboardReasoning = `The user wants a revenue dashboard that loads fast, so I'll build on what the app already has.**What the page needs**- A headline number for this month's revenue- A trend for the last 12 months- A breakdown by planThe \`stat-card-area-01\` block covers the first two in one card. For the breakdown, a ring chart reads better than a pie at small sizes and keeps the legend next to the values.I'll install both with one command and wire them to the existing \`/api/revenue\` route.`;const TOKEN_INTERVAL_MS = 45;/** Words with their trailing spaces, the way a model streams tokens. */const dashboardTokens = dashboardReasoning.match(/\S+\s*/g) ?? [];export function ReasoningDemo() { const [run, setRun] = useState(0); const [shown, setShown] = useState(0); const isStreaming = shown < dashboardTokens.length; useEffect(() => { if (!isStreaming) { return; } const timer = setTimeout( () => setShown((count) => count + 1), TOKEN_INTERVAL_MS, ); return () => clearTimeout(timer); }, [isStreaming, shown]); return ( <div className="flex w-full max-w-xl flex-col items-start gap-2"> <Reasoning className="w-full" isStreaming={isStreaming} key={run}> <ReasoningTrigger /> <ReasoningContent> {dashboardTokens.slice(0, shown).join("")} </ReasoningContent> </Reasoning> <Button disabled={isStreaming} onClick={() => { setShown(0); setRun((value) => value + 1); }} size="sm" variant="outline" > <RotateCcwIcon /> Stream again </Button> </div> );}Props
Reasoning
| Prop | Type | Default | Description |
|---|---|---|---|
isStreaming | boolean | false | Whether the model is still sending reasoning |
open | boolean | - | Whether the panel is open, when you control it |
defaultOpen | boolean | isStreaming | Whether the panel starts open |
onOpenChange | (open: boolean) => void | - | Called when the panel opens or closes |
duration | number | - | Seconds spent thinking, shown in the trigger |
ReasoningTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
getThinkingMessage | (isStreaming: boolean, duration?: number) => ReactNode | "Thinking..." or "Thought for N seconds" | The text in the trigger |
children | ReactNode | - | Replaces the whole trigger content |
ReasoningContent
| Prop | Type | Default | Description |
|---|---|---|---|
children | string | - | The reasoning text, as markdown |
The useReasoning hook returns isStreaming, isOpen, setIsOpen, and duration for custom parts inside Reasoning.
Adapted from AI Elements by Vercel (Apache 2.0).
