Elements

Reasoning

A collapsible renderer for assistant reasoning that follows the active message part.

Let me think about this step by step...

First, I need to consider the main factors involved in this problem.

fig. 01

Installation

Install manually
npx shadcn@latest add "@assistant-ui/reasoning"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

Reasoning gives a model's chain of thought its own track: a shimmering trigger while it streams, a scrollable trace once opened, and a plain collapsed row once the model moves on. With a runtime it follows the active reasoning message part; standalone you drive the same disclosure from props you own. It comes in two designs: the runtime variant renders one continuous markdown trace, and the static variant, ReasoningPanel, renders discrete titled steps along a timeline (see The step-panel design).

Getting started

A runtime streams reasoning message parts alongside the rest of a message. Consecutive ones group into a single group-reasoning part, and that group is what the disclosure wraps.

Group reasoning parts and compose the disclosure

components/assistant-ui/elements/thread.aui.tsx
import { MessagePrimitive, groupPartByType } from "@assistant-ui/react";
import {
  ReasoningContent,
  ReasoningRoot,
  ReasoningText,
  ReasoningTrigger,
} from "@/components/assistant-ui/elements/reasoning.aui";

<MessagePrimitive.GroupedParts
  groupBy={groupPartByType({ reasoning: ["group-reasoning"] })}
>
  {({ part, children }) => {
    if (part.type !== "group-reasoning") return null;
    const running = part.status.type === "running";
    return (
      <ReasoningRoot streaming={running}>
        <ReasoningTrigger active={running} />
        <ReasoningContent aria-busy={running}>
          <ReasoningText>{children}</ReasoningText>
        </ReasoningContent>
      </ReasoningRoot>
    );
  }}
</MessagePrimitive.GroupedParts>

groupBy decides which consecutive part types fold into one group; children is already the rendered parts for that range, so ReasoningText only has to lay them out. The kit's ReasoningGroup export targets an older components.ReasoningGroup prop on MessagePrimitive.Parts; MessagePrimitive.GroupedParts above is what Thread itself uses, so prefer it. The Thread element already ships this composition, so installing @assistant-ui/thread gives you the reasoning trace with none of the above (see Thread).

Render each reasoning part

children renders each reasoning part in the group through the same render function, so give the reasoning case a renderer:

case "reasoning":
  return <Reasoning {...part} />;

Reasoning renders the part's text as markdown with no disclosure chrome. A reasoning part that your groupBy leaves ungrouped reaches the same case; wrap it in ReasoningRoot / ReasoningTrigger / ReasoningContent yourself if it should still get the collapsible shell.

Anatomy

<div data-slot="reasoning-root" data-variant="outline">
  <button data-slot="reasoning-trigger">
    <BrainIcon />
    <span>{/* "Reasoning" or "Reasoning (12s)" */}</span>
    <ChevronDownIcon />
  </button>
  <div data-slot="reasoning-content">
    <div data-slot="reasoning-fade" /> {/* top edge, always */}
    <div data-slot="reasoning-text">{/* the trace */}</div>
    <div data-slot="reasoning-fade" /> {/* bottom edge, streaming only */}
  </div>
</div>

While streaming is true and the panel is open, the trace is a live, bottom-pinned preview: it autoscrolls to the newest tokens as they arrive and only stops if the reader scrolls up manually, resuming once they scroll back to the bottom. When streaming ends, the open state returns to defaultOpen, unless the reader has already toggled the disclosure by hand at least once, at which point their choice sticks and streaming no longer overrides it. ReasoningTrigger's active prop shimmers the label and duration adds the timing text; the chevron rotates on open regardless. The runtime ReasoningRoot also locks the thread viewport's scroll position during each open or close animation, so expanding a trace does not move the message list. The standalone root has no viewport to lock.

Examples

Variants

ReasoningRoot takes a variant: outline draws a border and padding (the default), ghost adds neither, and muted fills a soft background instead of a border.

<ReasoningRoot variant="muted" streaming={streaming}>
  {/* ... */}
</ReasoningRoot>

Trigger duration

Pass duration (seconds) to append a timing suffix to the trigger label:

<ReasoningTrigger active={streaming} duration={12} />
// renders: Reasoning (12s)

Where reasoning parts come from

The status.type on the group ("running" | "complete" | ...) is what drives streaming; it flips once the model's reasoning stream for that group ends. The group's timing spans the timing of its reasoning parts, and gains a completedAt once every timed part has finished, which is when a duration becomes worth showing:

const running = part.status.type === "running";
const timing = part.timing;
<ReasoningTrigger
  active={running}
  duration={
    timing?.completedAt === undefined
      ? undefined
      : Math.round((timing.completedAt - timing.startedAt) / 1000)
  }
/>

useDataStreamRuntime and useAssistantTransportRuntime stamp timing on each reasoning part as it streams. Other runtimes, the AI SDK and LangGraph ones and a ChatModelAdapter among them, leave it unset, so the trigger shows no duration unless the host supplies timing: { startedAt, completedAt } on its reasoning parts, in epoch milliseconds; an external store host that follows an agent's records does that from the times they carry.

API reference

Reasoning

ExportTypeNotes
ReasoningReasoningMessagePartComponentRenders the reasoning part's text as markdown. No disclosure chrome; use it for the reasoning parts inside a group, or for an ungrouped one.

Reasoning message part

FieldTypeDescription
textstringThe reasoning text so far.
statusMessagePartStreamStatus | undefinedStreaming status of this part.
unstable_summarystring | undefinedProvider-supplied summary, when present. Not rendered by Reasoning; read it yourself if you need it.

Sub-components

ReasoningRoot wraps the standalone root and additionally locks the thread viewport's scroll during the disclosure animation. ReasoningTrigger, ReasoningContent, ReasoningText, ReasoningFade, and reasoningVariants are re-exported unchanged from the standalone module below.

The step-panel design

The Static variant in the rail is a second design for the same disclosure: ReasoningPanel renders an ordered list of titled steps with a shimmering "Thinking" trigger that settles into a resting summary, instead of one continuous markdown trace. It is a single props-driven component with no runtime dependency:

npx shadcn@latest add "@assistant-ui/elements-reasoning-panel"

Wire it by mapping a message's "reasoning" parts into steps. s.message.parts is the store's own array, so selecting it directly is cheap; deriving steps still needs useMemo, since mapping to a fresh array on every call would re-render the panel on every update of the message.

components/assistant-ui/elements/reasoning-panel.tsx
"use client";

import { useMemo, useState } from "react";
import { useAuiState } from "@assistant-ui/react";
import {
  ReasoningPanel,
  type ReasoningStep,
} from "@/components/assistant-ui/elements/reasoning-panel";

function AssistantReasoning() {
  const parts = useAuiState((s) => s.message.parts);
  const steps = useMemo<ReasoningStep[]>(
    () =>
      parts.flatMap((part) =>
        part.type === "reasoning"
          ? [{ title: part.unstable_summary ?? "Thinking", body: part.text }]
          : [],
      ),
    [parts],
  );
  const streaming = useAuiState((s) => {
    if (s.message.status?.type !== "running") return false;
    return s.message.parts.some(
      (part) => part.type === "reasoning" && part.status.type === "running",
    );
  });
  const thoughtSeconds = useAuiState((s) => {
    const timings = s.message.parts.flatMap((part) =>
      part.type === "reasoning" && part.timing ? [part.timing] : [],
    );
    if (timings.length === 0) return undefined;
    let startedAt = Infinity;
    let completedAt = -Infinity;
    for (const timing of timings) {
      if (timing.completedAt === undefined) return undefined;
      startedAt = Math.min(startedAt, timing.startedAt);
      completedAt = Math.max(completedAt, timing.completedAt);
    }
    return Math.round((completedAt - startedAt) / 1000);
  });
  const [userOpen, setUserOpen] = useState<boolean | null>(null);

  if (steps.length === 0) return null;

  return (
    <ReasoningPanel
      steps={steps}
      visibleSteps={steps.length}
      streaming={streaming}
      open={userOpen ?? streaming}
      onOpenChange={setUserOpen}
      restingLabel={
        thoughtSeconds ? `Thought for ${thoughtSeconds}s` : "Done thinking"
      }
    />
  );
}

visibleSteps is steps.length, because the array grows as new reasoning parts stream in. open follows streaming until the reader toggles it once, after which their choice sticks.

The trigger swaps between the shimmering live label (plus the elapsed badge, when given) and restingLabel, animating its own width rather than jumping. visibleSteps clamps between 0 and steps.length; while streaming is true only the last shown step gets the pulsing marker.

ReasoningPanel

PropTypeDefaultDescription
stepsReasoningStep[]required{ title: string; body: string }[], in order.
visibleStepsnumberrequiredHow many leading steps to show; clamps between 0 and steps.length.
streamingbooleanrequiredSwaps the trigger between the live label and restingLabel, and marks the last shown step as active.
openbooleanrequiredWhether the collapsible is expanded.
onOpenChange(open: boolean) => voidrequiredCalled when the trigger is activated.
restingLabelstringrequiredTrigger text once streaming is false.
elapsedstring | undefinedShown next to the live label while streaming is true.
classNamestringMerged onto the root; starts as w-full max-w-sm.