Thinking trace
NewA “Thought for 4 seconds” line that opens into what the assistant did: its steps, its reasoning, its searches or its tool calls.
thinking-trace/thinking-trace.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Above an assistant’s answer, to show what it did first without pushing the answer down.
- While the work is still coming in: the header shimmers
Thinkingand rows arrive one by one. - Traces of four shapes: a checked-off plan, prose thoughts, a web search with its pages, or tool calls with an edit’s lines added and removed.
- When the reader may want to check the sources or the tools, but most of the time won’t open it.
When not to use
- The answer itself, or anything the reader must see. Folded content is easy to miss.
- Long-running jobs with a percentage or an ETA. Use a progress bar or an agent progress list.
- Asking the reader to decide something. Use an approval card; a trace only reports.
- Raw logs or full tool output. Link to them from a row; the trace keeps one line per item.
You can add this component with the shadcn CLI or by hand:
Into components/thinking-trace of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/thinking-trace.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { ThinkingTrace } from './thinking-trace';export function AnswerHeader({ steps, done }: { steps: { label: string }[]; done: boolean }) { return ( <ThinkingTrace mode="steps" switcher={false} autoplay={false} streaming={!done} arrived={steps.length} traces={{ steps: { label: 'Thought for 6 seconds', pendingLabel: 'Thinking', steps }, }} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
What an assistant did before it answered, folded into one quiet line. Press it and the trace opens under a thin thread: a checked-off plan, a few lines of reasoning, the query and the pages it found, or the tools it ran with an edit’s lines added and removed. While the work is still coming in, the line shimmers “Thinking”, rows arrive a few at a time and the latest one spins until it is done.
- The trace opens by animating its grid row from 0fr to 1fr, with its opacity, in 400 ms on cubic-bezier(0.23, 1, 0.32, 1), so it opens to its real height without measuring it; the chevron turns over in 300 ms.
- While open, the block holds at least 176 px, so the header stays put as rows arrive. The hold comes and goes at once; as the trace shuts, the block follows its closing row back to the middle of the stage.
- Each row rises 8 px and fades in over 320 ms, 120 ms after the row before it. The delay is the row’s place in the whole list, so a trace that arrives in two batches keeps one cadence. Rows are keyed by the kind of trace: switching kinds plays them in again.
- The thread on the left is as tall as the rows less 2 px. It is measured before paint whenever rows arrive (and by a ResizeObserver when the rows rewrap), and grows to it in 500 ms.
- While the work comes in, the header is clipped to a gradient twice its width, muted with a bright band in the middle, which slides across every 1.4 s; the latest step or page is a 12 px spinner (one bright quarter, 700 ms a turn), the latest thought or tool shimmers. Once done, the summary fades in over 350 ms.
- While on screen and untouched, the demo streams a trace in: the header shimmers, the trace opens at 0.84 s, the first half of the rows arrive at 1.44 s, the rest and the summary at 3.24 s, and it shuts at 5.84 s. Then it does the next kind. Any press hands it to the reader with the trace complete.
- Every colour is a light-dark() token on the root, so the trace follows the page’s color-scheme or the colorScheme prop.
| Prop | Type | Default | Description |
|---|---|---|---|
mode | 'steps' | 'reasoning' | 'search' | 'coding' | — | The trace shown; with it, the component is controlled (pair it with onModeChange). |
defaultMode | TraceMode | 'steps' | The trace shown first, when uncontrolled. |
onModeChange | (mode: TraceMode) => void | — | Called with the kind picked in the switch. |
open | boolean | — | Whether the trace is open; with it, the disclosure is controlled (pair it with onOpenChange). |
defaultOpen | boolean | false | Whether it starts open, when uncontrolled. |
onOpenChange | (open: boolean) => void | — | Called when the header is pressed, with the state it asks for. |
traces | Partial<Traces> | — | The content of each kind: its summary and pending words, and its steps, thoughts, query and pages (title, domain, href, tint, and how many more), or tool calls (action, target, lines added and removed). Any kind left out keeps the sample. |
streaming | boolean | false | The work is still coming in: the header shimmers its pending words and the latest row is still going. |
arrived | number | — | While streaming, how many rows have come in (a search’s query is the first). All of them by default. |
onSourceOpen | (index: number) => void | — | Makes a search’s pages without an href into buttons, called with the page’s index. |
defaultTool | number | null | — | The tool call shown picked at first, by its index in the coding trace. Setting it turns the demo off. |
onToolSelect | (index: number | null) => void | — | Called when a tool call is picked (its row stays pressed), or with null when it is unpicked. |
switcher | boolean | true | Shows the switch between the four kinds of trace. |
autoplay | boolean | true | While on screen and untouched, the trace streams in, opens and shuts by itself, kind by kind. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode,} from 'react';import { DEFAULT_TRACES, MODES, rowCount, type CodingTrace, type ReasoningTrace, type SearchTrace, type StepsTrace, type TraceMode, type Traces,/* Thinking trace (thinking-trace.tsx): a quiet stage with the folded trace in its middle and the switch between kinds of trace at its foot. At night it is near-black, the rows almost white; by day it is a pale grey, the rows near-black. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Memory. What moves is opacity, transforms, a background position, one grid row, one min-height and the 1 px thread's height, on a few small boxes; nothing runs while the trace is complete and still, but the spinner and the shimmer while the work comes in. */.root { /* The words: rows, the header and thoughts, and the muted grey of icons and notes. */ --ink: oklch(24.7% 0.006 258.361); --ink-2: oklch(50.6% 0.01 264.477); --ink-3: oklch(69.5% 0.009 264.505); /* The stage and its edge (also the thread); the spinner's ring. */ --canvas: oklch(96.1% 0.002 247.84); --line: oklch(94.6% 0.003 264.542); --line-strong: oklch(91.2% 0.005 258.326); /* A row under the pointer, the header under the pointer, a pressed tool call. */ --hover: oklch(97% 0.002 247.839); --hover-2: oklch(93.3% 0.003 247.86);// Copyright (c) 2026 Bruno Delorence. MIT License./* * What a trace can hold, one shape per kind, and the four the demo shows: an ice-cream shop's * assistant planning its summer menu. Each trace has the words its header says once it is done, * and the ones it says while the work is still coming in. */export type TraceMode = 'steps' | 'reasoning' | 'search' | 'coding';/** A step of a plan, with an optional note after it ("6 flavors"). */export type TraceStep = { label: string; meta?: string };/** A page the search found: its title, its site and, if it opens, where. */export type TraceSource = { title: string; domain: string; href?: string; /** The colour of its round badge. */ tint?: 'blue' | 'orange' | 'green';};/** A tool call: what it did, to what, and for an edit the lines it added and removed. */export type TraceTool = { action: string; target: string; added?: number; removed?: number };// Copyright (c) 2026 Bruno Delorence. MIT License./* * The trace's icons, on a 24-unit grid, all decorative (aria-hidden). Outlines take the current * colour; the sparkle is filled with it. *//** The four-pointed sparkle in front of the header. */export function Sparkle({ className }: { className?: string }) { return ( <svg className={className} width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"> <path d="M12 2 14.4 9.6 22 12 14.4 14.4 12 22 9.6 14.4 2 12 9.6 9.6Z" fill="currentColor" /> </svg> );}/** The header's chevron: points down while shut; the CSS turns it over when open. */export function Chevron({ className }: { className?: string }) { return ( <svg className={className} width="14" height="14" viewBox="0 0 24 24"'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The header is a button with aria-expanded and aria-controls; the shut trace is inert, so neither focus nor screen readers reach rows that cannot be seen.
- The header’s words are a status region: “Thinking” and then the summary are announced as they change.
- The switch is a radio group with one tab stop: arrow keys, Home and End move between kinds. Tool calls are toggle buttons (aria-pressed); a search’s pages are links when they have an href.
- A step or page still going says so to screen readers (“in progress”, “loading”); an edit’s counts are read as lines added and removed. The icons and the thread are decorative.
- Every control has a visible focus ring. Under reduced motion the demo never plays, the trace shows complete, and it opens and shuts without animation.