Thinking
A collapsible reasoning trace with elapsed time.
import { Thinking } from "@oration/canon/components/ai/thinking";import * as React from "react";export function Hero() { const steps = [ { label: "Reading the ticket and 3 earlier messages" }, { label: "Checking the payment in the ERP", detail: "PMT-58213 went out on September 25 by ACH.", }, { label: "Matching Northwind Freight's early-pay terms", detail: "2/10 net 30, enrolled in March.", }, { label: "Drafting a reply" }, ]; const [startedAt] = React.useState(() => Date.now()); const [shown, setShown] = React.useState(1); const [thoughtFor, setThoughtFor] = React.useState<number | undefined>(); React.useEffect(() => { if (thoughtFor !== undefined) return; const id = window.setTimeout(() => { if (shown < steps.length) setShown((n) => n + 1); else setThoughtFor( Math.max(1, Math.round((Date.now() - startedAt) / 1000)), ); }, 900); return () => window.clearTimeout(id); }, [shown, steps.length, thoughtFor, startedAt]); return ( <div className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"> <Thinking steps={steps.slice(0, shown)} status={thoughtFor === undefined ? "running" : "done"} startedAt={startedAt} elapsed={thoughtFor} /> {thoughtFor !== undefined ? ( <p className="text-sm leading-relaxed text-pretty text-foreground"> The $412.50 difference on INV-20931 is Northwind Freight's 2% early-payment discount. Payment PMT-58213 went out on September 25 for $20,212.50, and the remittance advice lists the discount as its own line. </p> ) : null} </div> );}Usage#
Thinking is a one-line, collapsible reasoning trace. While the model works it reads Thinking… 4s with a small pixel grid and the latest step beside it; when it finishes it settles to Thought for 6s. Opening it shows every step on a thin rail, each with an optional one-line detail. It exists so people can check how an answer was reached without the reasoning competing with the answer. The common mistake is opening it by default: the trace stays folded above the answer, and the answer leads.
When to use
- At the top of a Copilot answer, before tool calls and the streamed text.
- Above a retrieval answer, such as a knowledge base test, to show how the passages were found.
- When the steps are worth auditing later: which calls were read, which rules were checked.
- When the wait is long enough that the latest step reassures people something is happening.
When not to use
- For a wait with nothing to show but a label and a timer. Use AI loader
- For a plan the person will watch execute, where steps can fail or be skipped. Use Task list
- For tool calls with arguments and results. Use Tool chip
- For the answer itself. Use Streaming text
- For a proposed change that needs a decision. Use Approval card
The answer leads
Shimmer only while working
Anatomy#
- Read the ticket and 3 earlier messages
- Checked the payment in the ERPPMT-58213 went out on September 25.
- Pixel grid. The small 3×3 grid from AI loader at 80% opacity. It ripples while running and rests when done.
- Summary. Thinking… (shimmering) or Thought for, in medium weight, then the elapsed time in regular tabular figures.
- Chevron. Present when there are steps. Turns 90° when open.
- Latest step. While running and folded, the newest step trails the summary in Slate Meta and truncates.
- Rail. A 1px hairline on the left of the open list, aligned under the grid.
- Step. 13px text. The current step shimmers in medium weight while running; the rest are ink at 80%.
- Detail. Optional 12px Slate Meta line under a step.
Examples#
Running and done
While running, the summary ticks and the latest step trails it. Done, it settles to one quiet line with the total time.
import { Thinking } from "@oration/canon/components/ai/thinking";import * as React from "react";export function RunningAndDone() { const [startedAt] = React.useState(() => Date.now() - 4000); const steps = [ { label: "Pulling containment for the last 14 days" }, { label: "Splitting the drop by intent and transfer reason" }, ]; return ( <div className="flex w-full max-w-md flex-col gap-5"> <Thinking steps={steps} status="running" startedAt={startedAt} /> <Thinking steps={steps} status="done" elapsed={6} /> </div> );}Steps with details
Open, every step sits on the rail. A detail line carries the one fact the step turned up.
- Reading the current prompt and its 14 variables
- Comparing against last week's failed calls31 calls ended in a repeat loop, 12 were transferred after a date question.
- Checking which instructions the model skipped
- Drafting the smallest change that fixes both
import { Thinking } from "@oration/canon/components/ai/thinking";export function WithDetails() { return ( <div className="w-full max-w-md"> <Thinking status="done" elapsed={4} defaultOpen steps={[ { label: "Reading the current prompt and its 14 variables", }, { label: "Comparing against last week's failed calls", detail: "31 calls ended in a repeat loop, 12 were transferred after a date question.", }, { label: "Checking which instructions the model skipped" }, { label: "Drafting the smallest change that fixes both" }, ]} /> </div> );}Before a retrieval answer
In a knowledge base test the trace starts open, with present-tense steps, then settles to past tense when the answer arrives. Keeping one instance keeps its open state.
Can a supplier change bank details over the phone?
- Embedding the question
- Searching 1,240 chunksHybrid search, keyword and vector
- Reranking the top 8 passages
import { Thinking } from "@oration/canon/components/ai/thinking";import * as React from "react";export function Retrieval() { const [startedAt] = React.useState(() => Date.now()); const [done, setDone] = React.useState(false); React.useEffect(() => { const id = window.setTimeout(() => setDone(true), 2400); return () => window.clearTimeout(id); }, []); return ( <div className="flex w-full max-w-md flex-col gap-3"> <p className="self-end rounded-xl bg-muted px-3 py-2 text-13 text-foreground"> Can a supplier change bank details over the phone? </p> <Thinking status={done ? "done" : "running"} startedAt={startedAt} defaultOpen steps={ done ? [ { label: "Embedded the question" }, { label: "Searched 1,240 chunks" }, { label: "Kept 3 passages above 0.5" }, ] : [ { label: "Embedding the question" }, { label: "Searching 1,240 chunks", detail: "Hybrid search, keyword and vector", }, { label: "Reranking the top 8 passages" }, ] } /> {done ? ( <p className="text-13 leading-5 text-pretty text-foreground"> No. Bank changes need a verified callback to the number already on file, and the agent can't apply them during the call. </p> ) : null} </div> );}Controlled
Pass open and onOpenChange to drive it from outside, for example from a preference to always show reasoning.
- Reading transferred calls from Thursday96 transfers, 81 of them mention a remittance.
- Checking tool errors in the same window
import { Thinking } from "@oration/canon/components/ai/thinking";import { Switch } from "@oration/canon/components/switch";import * as React from "react";export function Controlled() { const [open, setOpen] = React.useState(true); return ( <div className="flex w-full max-w-md flex-col gap-4"> <Switch label="Always show reasoning" checked={open} onCheckedChange={setOpen} /> <Thinking status="done" elapsed={3} open={open} onOpenChange={setOpen} steps={[ { label: "Reading transferred calls from Thursday", detail: "96 transfers, 81 of them mention a remittance.", }, { label: "Checking tool errors in the same window" }, ]} /> </div> );}No steps
With an empty steps array it is a plain line with no chevron, for a model that reports only how long it thought.
import { Thinking } from "@oration/canon/components/ai/thinking";export function NoSteps() { return <Thinking status="done" elapsed={3} steps={[]} />;}States#
- Reading the ticket
- Checking the payment
- Reading the ticket
- Checking the payment
import { Thinking } from "@oration/canon/components/ai/thinking";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function StatesMatrix() { const [startedAt] = React.useState(() => Date.now() - 4000); const steps = [ { label: "Reading the ticket" }, { label: "Checking the payment" }, ]; const cells = [ { label: "Running, folded", running: true, open: false, className: "" }, { label: "Running, open", running: true, open: true, className: "" }, { label: "Done, folded", running: false, open: false, className: "" }, { label: "Done, open", running: false, open: true, className: "" }, { label: "Hover", running: false, open: false, className: "[&_[data-slot=collapsible-trigger]]:text-foreground", }, { label: "Focus visible", running: false, open: false, className: "[&_[data-slot=collapsible-trigger]]:ring-3 [&_[data-slot=collapsible-trigger]]:ring-ring/40", }, ]; return ( <div className="grid w-full grid-cols-1 items-start gap-x-6 gap-y-5 sm:grid-cols-2"> {cells.map((cell) => ( <div key={cell.label} className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground"> {cell.label} </span> <Thinking steps={steps} status={cell.running ? "running" : "done"} startedAt={startedAt} elapsed={cell.running ? undefined : 4} open={cell.open} className={cn("pointer-events-none", cell.className)} /> </div> ))} </div> );}| State | Treatment |
|---|---|
| Running, folded | Grid rippling, Thinking… shimmering, seconds ticking, the latest step beside it. |
| Running, open | Steps listed on the rail. New steps rise 4px and fade in on spring.moderate; the last one shimmers. |
| Done, folded | Thought for 6s. The grid rests and the counter stops at the finishing time. |
| Done, open | Every step in plain text, details below. |
| Hover | The summary turns from Slate Meta to ink over 150ms. |
| Focus visible | A 3px Focus Indigo ring at 40% around the summary. |
| No steps | With an empty steps array it is a plain line, not a button, with no chevron. |
| Reduced motion | The grid holds still, shimmer stops, steps appear without rising and the list opens without animating height. |
Behavior#
- It is a Base UI Collapsible. The summary is the trigger, with
aria-expanded; Enter and Space toggle it. - Uncontrolled by default (
defaultOpen). PassopenandonOpenChangeto control it, for example from a preference. - The counter comes from
useElapsed: whole seconds sincestartedAt, or since mount. It ticks while running and freezes whenstatusturnsdone. elapsedoverrides the counter. Use it for traces that finished before this render, such as a turn loaded from history.- A done trace never reads Thought for 0s; the minimum is one second.
- Steps already on screen at mount don't animate; only steps added later rise in.
- The open list animates its height over 200ms through the shared collapsible styles.
Do and don't#
The $412.50 difference is the 2% early-payment discount.
- Reading the ticket
- Checking the payment in the ERP
- Matching the early-pay terms
The $412.50 difference is the 2% early-payment discount.
- Reading 31 failed calls from last week
- Checking which instructions the model skipped
- Drafting the smallest change that fixes both
- Analyzing
- Processing data
- Thinking deeply
Content#
- Write steps as an -ing verb plus the object, without an ellipsis: Reading the ticket and 3 earlier messages, Checking the payment in the ERP.
- When a trace loads already finished, write its steps in the past tense: Embedded the question, Kept 3 passages above 0.5.
- Use
detailfor the one fact the step turned up: 31 calls ended in a repeat loop, 12 were transferred after a date question. - Three to five steps is enough. Merge steps that differ only in wording.
- The summary words (Thinking…, Thought for) are fixed by the component.
Accessibility#
- The summary is a
<button>witharia-expanded. Its name includes the elapsed time and, while folded, the latest step. - A separate visually hidden
role="status"says Thinking while running and Thought for 6 seconds when done, so the finish is announced once. - The ticking seconds and new steps are not announced one by one, which keeps screen readers quiet while it works.
- Under reduced motion nothing moves; the words carry the state.
| Keys | Action |
|---|---|
| Tab | Moves to the summary when there are steps. |
| Enter | Opens or closes the steps. |
| Space | Opens or closes the steps. |
Design tokens#
| Token | Used for |
|---|---|
--muted-foreground | Summary text at rest, latest step, details |
--foreground | Grid cells, summary on hover, finished steps at 80% |
--border | The rail |
--ring | Focus ring at 40% |
text-shimmer | Thinking… and the current step |
spring.moderate | Entrance of new steps |
text-13 | Summary and step labels; details at text-xs |
API reference#
Thinking
The trace. Also exported: the ThinkingStep type, { label: string; detail?: string }.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
stepsRequired | ThinkingStep[] | No default | Steps in order. The last one is current while running. |
statusRequired | "running" | "done" | No default | Running ticks and shimmers; done settles to Thought for. |
startedAt | number | No default | Epoch ms the reasoning began. Defaults to mount. |
elapsed | number | No default | Seconds. Overrides the live counter, for traces that finished earlier. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
open | boolean | No default | Controlled open state. |
onOpenChange | (open: boolean) => void | No default | Called when the person toggles it. |
className | string | No default | Merged onto the root. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
elapsed isn't rounded. The knowledge base panel passes 2.2 and the summary reads Thought for 2.2s.
There is no stopped or failed status. When someone stops a Copilot turn mid-thought, the trace reads Thought for 3s as if it had finished.
The knowledge base panel swaps a running trace (open, present tense) for a new done one (folded, past tense), so the open state is lost at the switch. One instance with changing steps would keep it.
The summary words can't be changed, so a trace for retrieval still says Thinking… rather than Searching….
open and onOpenChange aren't used anywhere in the product yet.