Elements

Elements · AUI connected · AUI

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

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 a lone reasoning part

Most runtimes stream reasoning as consecutive parts, but a single ungrouped reasoning part still needs a renderer:

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

Reasoning renders the part's text as markdown with no disclosure chrome. Wrap it in ReasoningRoot / ReasoningTrigger / ReasoningContent yourself if a lone part 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 drives both the shimmering label and the timing text; the chevron rotates on open regardless. The runtime ReasoningRoot additionally locks the surrounding thread viewport's scroll position for the duration of each open or close animation, so expanding a trace never yanks the message list; the standalone root has no viewport to lock, so it skips that.

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, which is also when a duration becomes worth showing:

const running = part.status.type === "running";
<ReasoningTrigger active={running} duration={running ? undefined : elapsedSeconds} />

API reference

Reasoning

ExportTypeNotes
ReasoningReasoningMessagePartComponentRenders the reasoning part's text as markdown. No disclosure chrome; use it for an ungrouped reasoning part.

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 store update.

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

import { useMemo, useState } from "react";
import { useAuiState, useMessageTiming } 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 timing = useMessageTiming();
  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={
        timing?.totalStreamTime
          ? `Thought for ${Math.round(timing.totalStreamTime / 1000)}s`
          : "Done thinking"
      }
    />
  );
}

visibleSteps is just steps.length: the array itself 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.