Reasoning

A disclosure for the model's thinking. Reasoning opens itself while a stream is arriving and closes itself once it stops, and reports how long the thinking took.

Checked the contract, then measured the control.

Import

import {
  Reasoning,
  ReasoningContent,
  ReasoningTrigger,
} from '@elirobinson/ai-elements/components/reasoning';
import {
  ChainOfThought,
  ChainOfThoughtContent,
  ChainOfThoughtHeader,
  ChainOfThoughtStep,
} from '@elirobinson/ai-elements/components/chain-of-thought';
import { Shimmer } from '@elirobinson/ai-elements/components/shimmer';

How it opens and closes

isStreaming drives the whole thing, and the rules are worth reading before you fight them:

  • It auto-opens whenever isStreaming is true and it is closed — unless you passed defaultOpen={false}, which is the one opt-out. This effect re-runs, so a reader who closes the panel mid-stream has it reopened.
  • It auto-closes one second after isStreaming goes false, and only once per mount. After that single auto-close, reopening the panel sticks.
  • duration is measured from when streaming started to when it stopped, in whole seconds, and the trigger reads "Thought for N seconds" — or "Thought for a few seconds" when there is no duration to report, which is what the demo above shows. Pass duration to control it.
  • While streaming, the trigger's label is a Shimmer reading "Thinking...".

ReasoningContent takes a string and renders it as markdown through Streamdown.

Chain of thought

Read the contract
Measure the control

ChainOfThoughtStep takes label, an optional description, an icon, and a status of complete, active or pending, which is styled rather than announced — the status is colour and icon only.

Motion

Shimmer is the sweeping-highlight text used for "Thinking..." and for a streaming PlanTitle. It animates through Framer Motion on an infinite linear repeat.

Thinking

Nothing in the vendored tree reads prefers-reduced-motion — not Shimmer, and not the animate-in entrance each ChainOfThoughtStep plays. The demos on this page are frozen so the visual suite can photograph them, and freezing Shimmer in particular takes more than the two obvious levers: the stage's animation: none is CSS-only and Framer Motion does not use CSS animation, and MotionConfig's reducedMotion="always" drops only transform and layout animations — Shimmer animates background-position, so it survives both. The stage pins that one property with !important, which beats the value Framer writes inline.

If you are honouring reduced motion, reducedMotion="always" alone will not stop this component. In your app it moves until you stop it yourself, at the boundary.

Accessibility

  • ReasoningTrigger and ChainOfThoughtHeader are full-width disclosure rows, both floored to var(--target) — see below. Both come from the shared collapsible, so Enter and Space toggle them and aria-expanded follows the state.
  • Neither component sets aria-live — and neither does anything else in the package: there is no explicit aria-live attribute in the vendored tree. Three roles imply one, and none of them is on this page: Conversation's role="log", the shared alert's role="alert" (which Confirmation renders — see Tools and tasks), and the shared spinner's role="status". So whether a reasoning stream is announced is decided by where you render it and by the aria-live you put on the thread — see Conversation and messages.

What this system changed

PatchWhat changed, and why
reasoning-trigger-floorprimary — reasoning.tsx — ReasoningTrigger. Measured 1248x20. Same shape as SourcesTrigger: a full-width row at 20px.
chain-of-thought-header-floorprimary — chain-of-thought.tsx — ChainOfThoughtHeader. Measured 1248x20. Same shape again.

Read from vendoredElementTargets in @elirobinson/ai-patterns/contracts, which the browser audit writes.