# Thinking trace

> A “Thought for 4 seconds” line that opens into what the assistant did: its steps, its reasoning, its searches or its tool calls.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/thinking-trace. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Stage**: A quiet rounded panel with the trace in its middle, light or dark from the page’s color-scheme or the `colorScheme` prop.
2. **Header**: One button, sparkle, words and chevron, that folds the trace open and shut. Its hover reaches 6 px past them.
3. **Summary**: What was done, once it is done (`Thought for 4 seconds`); the pending words under a sweeping shimmer while it isn’t. It is the live region.
4. **Chevron**: Points down while shut and turns over in 300 ms as the trace opens.
5. **Thread**: A 1 px line from under the sparkle down the rows, measured to their height and grown to it in 500 ms as rows arrive.
6. **Row**: One item of the trace. Each rises 8 px and fades in, 120 ms after the one before it.
7. **Kind switch**: A segmented radio group between Steps, Reasoning, Search and Coding, with arrow keys. `switcher={false}` hides it.

## 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 `Thinking` and 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.

## Installation

Install it with the shadcn CLI, into components/thinking-trace of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/thinking-trace.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```tsx
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 },
      }}
    />
  );
}
```

## States

- **Folded**: The work is done and shut: the summary alone, muted.
- **Thinking**: `streaming` with nothing in yet: the pending words shimmer, every 1.4 s, and keep moving.
- **Rows arriving**: `streaming` with `arrived={2}`: the latest row still going, behind a spinner that keeps turning.
- **Steps, open**: A plan checked off, with an optional note after a step.
- **Reasoning**: The thoughts in prose, wrapping as wide as the trace.
- **Search**: The query, then each page with its tinted badge, and how many more it found.
- **Tool picked**: A pressed tool call stays filled until it is pressed again (`defaultTool`).

## How it works

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

## API reference

- **mode** (`'steps' | 'reasoning' | 'search' | 'coding'`): The trace shown; with it, the component is controlled (pair it with onModeChange).
- **defaultMode** (`TraceMode`, default `'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`, default `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`, default `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`, default `true`): Shows the switch between the four kinds of trace.
- **autoplay** (`boolean`, default `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.

## Accessibility

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

## Source

### thinking-trace/thinking-trace.tsx

```tsx
// 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,
} from './data';
import { Check, Chevron, Globe, Magnifier, Sparkle } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './thinking-trace.module.css';

export type { TraceMode, Traces } from './data';

/*
 * Thinking trace: what an assistant did before it answered, folded into one line ("Thought for 4
 * seconds") that opens into the trace. Four kinds of trace, picked from a segmented switch under
 * it: Steps (a checked-off plan), Reasoning (its thoughts, in prose), Search (the query and the
 * pages it found) and Coding (the tools it ran, an edit's lines added and removed).
 *
 * How it moves
 * - The trace opens by animating its grid row from 0fr to 1fr, with its opacity, in 400 ms on a
 *   steep ease-out; the chevron turns over in 300 ms. While open, the block holds at least 176 px,
 *   so the header stays put as rows arrive.
 * - Each row rises 8 px into place and fades in over 320 ms, 120 ms after the row before it, so a
 *   trace that arrives in two batches still keeps one cadence. Rows are keyed by the kind of
 *   trace: switching kinds plays them in again (unseen while it is shut).
 * - The thread on the left is as tall as the rows minus 2 px, measured by a ResizeObserver, and
 *   grows to it in 500 ms whenever rows arrive.
 * - While the work comes in, the header shimmers ("Thinking": a bright band sweeps the word every
 *   1.4 s) and the latest row is still going: a spinner for a step or a page, a shimmer for a
 *   thought or a tool. Once done, the header fades to its summary in 350 ms.
 * - The demo, while on screen and untouched, plays the trace as it would stream in: header
 *   shimmering, it opens at 0.84 s, the first half of the rows come in 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.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

/** The demo's beats, in ms: how long each part of a lap lasts before the next. */
const PHASES = {
  start: 840,
  open: 600,
  first: 1800,
  done: 2600,
  close: 2800,
} as const;
type Phase = keyof typeof PHASES | 'idle';
const NEXT: Record<keyof typeof PHASES, keyof typeof PHASES> = {
  start: 'open',
  open: 'first',
  first: 'done',
  done: 'close',
  close: 'start',
};
/** Each row comes in this long after the one before it. */
const STAGGER = 120;

const delay = (index: number) => ({ animationDelay: `${index * STAGGER}ms` }) as CSSProperties;

export function ThinkingTrace({
  mode: controlledMode,
  defaultMode = 'steps',
  onModeChange,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  traces: given,
  streaming = false,
  arrived,
  onSourceOpen,
  onToolSelect,
  defaultTool,
  switcher = true,
  autoplay: autoplayProp = true,
  colorScheme,
  className = '',
}: {
  /** The trace shown; with it, the component is controlled (pair it with onModeChange). */
  mode?: TraceMode;
  /** The trace shown first, when uncontrolled. */
  defaultMode?: TraceMode;
  /** Called with the kind picked in the switch. */
  onModeChange?: (mode: TraceMode) => void;
  /** Whether the trace is open; with it, the disclosure is controlled (pair it with onOpenChange). */
  open?: boolean;
  /** Whether it starts open, when uncontrolled. */
  defaultOpen?: boolean;
  /** Called when the header is pressed, with the state it asks for. */
  onOpenChange?: (open: boolean) => void;
  /** The traces' content; any kind left out keeps the sample. */
  traces?: Partial<Traces>;
  /** The work is still coming in: the header shimmers its pending words, the last row is going. */
  streaming?: boolean;
  /** While streaming, how many rows have come in (the search's query is the first). All by default. */
  arrived?: number;
  /** Makes a search's pages without an href buttons, called with the page's index. */
  onSourceOpen?: (index: number) => void;
  /** Called when a tool call is picked (its row stays pressed), or with null when unpicked. */
  onToolSelect?: (index: number | null) => void;
  /** The tool call shown picked at first (its index in the coding trace); setting it turns the demo off. */
  defaultTool?: number | null;
  /** Shows the switch between the four kinds of trace. */
  switcher?: boolean;
  /** While on screen and untouched, the trace streams in, opens and shuts by itself, kind by kind. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>();
  // A tool call picked from the start is a locked state: no demo.
  const autoplay = autoplayProp && defaultTool === undefined;
  const traces: Traces = { ...DEFAULT_TRACES, ...given };

  const [ownMode, setOwnMode] = useState(defaultMode);
  const mode = controlledMode ?? ownMode;
  const [ownOpen, setOwnOpen] = useState(defaultOpen);
  const open = controlledOpen ?? ownOpen;
  const [tool, setTool] = useState<number | null>(defaultTool ?? null);

  // The demo: a lap per kind of trace. It starts at once if it may play, so its first frame on
  // screen is the header thinking; 'idle' shows the trace complete.
  const [phase, setPhase] = useState<Phase>(autoplay ? 'start' : 'idle');
  const demo = autoplay && playing;

  // No demo: the trace stays complete (autoplay off, or reduced motion, where `playing` never
  // comes on; the media query is read here too, since `playing` starts off either way).
  useEffect(() => {
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (!autoplay || reduced) setPhase('idle');
  }, [autoplay]);

  // The demo's clock: each phase lasts its beat, then the next. Off screen it waits where it is.
  // Back from the reader, it begins a lap on the next kind.
  useEffect(() => {
    if (!demo) return;
    if (phase === 'idle') {
      const id = window.setTimeout(() => {
        setOwnMode((now) => MODES[(MODES.findIndex((m) => m.id === now) + 1) % MODES.length].id);
        setOwnOpen(false);
        setTool(null);
        setPhase('start');
      }, 0);
      return () => window.clearTimeout(id);
    }
    const id = window.setTimeout(() => {
      const next = NEXT[phase];
      if (next === 'open') setOwnOpen(true);
      if (next === 'close') setOwnOpen(false);
      if (next === 'start') {
        setOwnMode((now) => MODES[(MODES.findIndex((m) => m.id === now) + 1) % MODES.length].id);
        setTool(null);
      }
      setPhase(next);
    }, PHASES[phase]);
    return () => window.clearTimeout(id);
  }, [demo, phase]);

  const total = rowCount(mode, traces);
  const firstBatch = Math.ceil(total / 2);
  const inDemo = phase !== 'idle';
  const live = inDemo ? phase === 'start' || phase === 'open' || phase === 'first' : streaming;
  const count = inDemo
    ? phase === 'start' || phase === 'open'
      ? 0
      : phase === 'first'
        ? firstBatch
        : total
    : streaming
      ? Math.min(total, arrived ?? total)
      : total;
  /** The row still going, while streaming: the latest to come in. */
  const going = live ? count - 1 : -1;
  const tall = open || (inDemo && phase !== 'close');

  // The reader takes over: the demo stops, the trace shows complete.
  const handOver = () => {
    take();
    setPhase('idle');
  };
  const toggle = () => {
    handOver();
    if (controlledOpen === undefined) setOwnOpen(!open);
    onOpenChange?.(!open);
  };
  const pick = (next: TraceMode) => {
    handOver();
    setTool(null);
    if (controlledMode === undefined) setOwnMode(next);
    onModeChange?.(next);
  };
  const press = (index: number) => {
    handOver();
    const next = tool === index ? null : index;
    setTool(next);
    onToolSelect?.(next);
  };

  // The thread on the left runs the rows' height, less 2 px; it grows in CSS. It is measured
  // before paint whenever rows arrive (so it starts growing on the frame they appear), and again
  // whenever the rows change size on their own (a narrower stage wrapping a thought).
  const list = useRef<HTMLUListElement>(null);
  const [thread, setThread] = useState(0);
  useLayoutEffect(() => {
    const element = list.current;
    if (element) setThread(Math.max(0, element.offsetHeight - 2));
  }, [mode, count]);
  useEffect(() => {
    const element = list.current;
    if (!element) return;
    const observer = new ResizeObserver(() => setThread(Math.max(0, element.offsetHeight - 2)));
    observer.observe(element);
    return () => observer.disconnect();
  }, [mode]);

  const buttons = useRef<(HTMLButtonElement | null)[]>([]);
  const onSwitchKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const index = MODES.findIndex((m) => m.id === mode);
    const moves: Record<string, number> = {
      ArrowRight: index + 1,
      ArrowDown: index + 1,
      ArrowLeft: index - 1,
      ArrowUp: index - 1,
      Home: 0,
      End: MODES.length - 1,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    const next = (moves[event.key] + MODES.length) % MODES.length;
    pick(MODES[next].id);
    buttons.current[next]?.focus();
  };

  const panel = useId();
  const groupLabel = useId();
  const header = traces[mode];
  const label = live ? header.pendingLabel : header.label;

  let rows: ReactNode;
  if (mode === 'steps') rows = <StepRows trace={traces.steps} count={count} going={going} />;
  else if (mode === 'reasoning')
    rows = <ThoughtRows trace={traces.reasoning} count={count} going={going} />;
  else if (mode === 'search')
    rows = (
      <SearchRows
        trace={traces.search}
        count={count}
        going={going}
        done={!live}
        onOpen={onSourceOpen}
      />
    );
  else
    rows = (
      <ToolRows trace={traces.coding} count={count} going={going} picked={tool} onPress={press} />
    );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-switcher={switcher || undefined}
    >
      <div className={styles.block} data-part="block" data-tall={tall || undefined}>
        <button
          type="button"
          className={styles.header}
          data-part="header"
          aria-expanded={open}
          aria-controls={panel}
          onClick={toggle}
        >
          <Sparkle className={styles.sparkle} />
          {/* The header's words are the live region: "Thinking", then the summary. */}
          <span role="status" className={styles.status}>
            <span
              key={`${mode}-${live}`}
              className={live ? styles.pending : styles.label}
              data-part="label"
            >
              {label}
            </span>
          </span>
          <Chevron className={styles.chevron} />
        </button>
        <div
          id={panel}
          className={styles.panel}
          data-part="panel"
          data-open={open || undefined}
          inert={!open}
        >
          <div className={styles.clip}>
            <div className={styles.trace}>
              <span
                className={styles.thread}
                data-part="thread"
                style={{ height: thread }}
                aria-hidden="true"
              />
              <ul ref={list} key={mode} className={styles.rows}>
                {rows}
              </ul>
            </div>
          </div>
        </div>
      </div>
      {switcher && (
        <div
          className={styles.switch}
          data-part="switch"
          role="radiogroup"
          aria-labelledby={groupLabel}
          onKeyDown={onSwitchKey}
        >
          <span id={groupLabel} className={styles.hidden}>
            Kind of trace
          </span>
          {MODES.map((m, index) => (
            <button
              key={m.id}
              ref={(element) => {
                buttons.current[index] = element;
              }}
              type="button"
              role="radio"
              aria-checked={m.id === mode}
              tabIndex={m.id === mode ? 0 : -1}
              className={styles.option}
              data-part="option"
              onClick={() => pick(m.id)}
            >
              {m.name}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

/** A spinner for the row still going: a ring with one bright quarter, 700 ms a turn. */
function Spinner() {
  return <span className={styles.spinner} data-part="spinner" aria-hidden="true" />;
}

function StepRows({ trace, count, going }: { trace: StepsTrace; count: number; going: number }) {
  return trace.steps.slice(0, count).map((step, index) => (
    <li key={index} className={styles.row} data-part="row" style={delay(index)}>
      {index === going ? <Spinner /> : <Check className={styles.icon} />}
      <span className={styles.title}>{step.label}</span>
      {step.meta && <span className={styles.meta}>{step.meta}</span>}
      {index === going && <span className={styles.hidden}>, in progress</span>}
    </li>
  ));
}

function ThoughtRows({
  trace,
  count,
  going,
}: {
  trace: ReasoningTrace;
  count: number;
  going: number;
}) {
  return trace.thoughts.slice(0, count).map((thought, index) => (
    <li key={index} className={styles.row} data-part="row" style={delay(index)}>
      <span className={index === going ? styles.thoughtGoing : styles.thought} data-part="thought">
        {thought}
      </span>
    </li>
  ));
}

function SearchRows({
  trace,
  count,
  going,
  done,
  onOpen,
}: {
  trace: SearchTrace;
  count: number;
  going: number;
  done: boolean;
  onOpen?: (index: number) => void;
}) {
  const pages = trace.sources.slice(0, Math.max(0, count - 1));
  return (
    <>
      {count > 0 && (
        <li className={styles.query} data-part="query">
          <Magnifier className={styles.icon} />
          <span className={styles.queryText}>{trace.query}</span>
        </li>
      )}
      {pages.map((source, index) => {
        const isGoing = index + 1 === going;
        const content = (
          <>
            {isGoing ? (
              <Spinner />
            ) : (
              <span className={styles.badge} data-part="badge" data-tint={source.tint ?? 'blue'}>
                <Globe />
              </span>
            )}
            <span className={styles.title}>
              <span className={styles.underline}>{source.title}</span>
            </span>
            <span className={styles.meta}>{source.domain}</span>
            {isGoing && <span className={styles.hidden}>, loading</span>}
          </>
        );
        return (
          <li key={index} className={styles.item} style={delay(index)}>
            {source.href ? (
              <a
                className={styles.row}
                data-part="row"
                data-action
                href={source.href}
                target="_blank"
                rel="noreferrer"
              >
                {content}
              </a>
            ) : onOpen ? (
              <button
                type="button"
                className={styles.row}
                data-part="row"
                data-action
                onClick={() => onOpen(index)}
              >
                {content}
              </button>
            ) : (
              <div className={styles.row} data-part="row">
                {content}
              </div>
            )}
          </li>
        );
      })}
      {done && trace.more ? (
        <li className={styles.more} data-part="more">
          +{trace.more} more
        </li>
      ) : null}
    </>
  );
}

function ToolRows({
  trace,
  count,
  going,
  picked,
  onPress,
}: {
  trace: CodingTrace;
  count: number;
  going: number;
  picked: number | null;
  onPress: (index: number) => void;
}) {
  return trace.tools.slice(0, count).map((tool, index) => (
    <li key={index} className={styles.item} style={delay(index)}>
      <button
        type="button"
        className={styles.row}
        data-part="row"
        data-action
        aria-pressed={picked === index}
        onClick={() => onPress(index)}
      >
        <span className={index === going ? styles.titleGoing : styles.title}>{tool.action}</span>
        <span className={styles.code}>{tool.target}</span>
        {(tool.added !== undefined || tool.removed !== undefined) && (
          <span className={styles.diff} data-part="diff">
            {tool.added !== undefined && (
              <span className={styles.added} aria-label={`${tool.added} lines added`}>
                +{tool.added}
              </span>
            )}{' '}
            {tool.removed !== undefined && (
              <span className={styles.removed} aria-label={`${tool.removed} lines removed`}>
                {'−'}
                {tool.removed}
              </span>
            )}
          </span>
        )}
      </button>
    </li>
  ));
}
```

### thinking-trace/thinking-trace.module.css

```css
/* 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);
  --inset: oklch(97.9% 0.002 247.839);
  /* The switch's track and its picked button, with the button's edge and lift: a ring and a soft
     glow by day, a faint light ring and a drop shadow at night. */
  --field: oklch(96.1% 0.001 286.375);
  --surface: oklch(100% 0 0);
  --button-ring: oklch(91.2% 0.005 258.326);
  --button-glow: rgb(0 0 0 / 0.04);
  --button-drop: transparent;
  /* The pages' badges, and an edit's lines added and removed. */
  --blue: oklch(62.6% 0.205 254.947);
  --orange: oklch(68.9% 0.179 49.902);
  --green: oklch(60.3% 0.155 150.883);
  --red: oklch(62.1% 0.192 23.042);
  --focus: #387bff;
  /* The trace's ease: fast out of the gate, a long soft landing. */
  --ease: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-ui: cubic-bezier(0.4, 0, 0.2, 1);
  position: relative;
  container-type: inline-size;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 272px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --hover-2: light-dark(oklch(93.3% 0.003 247.86), oklch(31.8% 0.007 274.747));
    --inset: light-dark(oklch(97.9% 0.002 247.839), oklch(24.3% 0.004 264.492));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --button-ring: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --button-glow: light-dark(rgb(0 0 0 / 0.04), transparent);
    --button-drop: light-dark(transparent, oklch(0% 0 0 / 0.3));
    --blue: light-dark(oklch(62.6% 0.205 254.947), oklch(68% 0.173 253.301));
    --orange: light-dark(oklch(68.9% 0.179 49.902), oklch(74.6% 0.156 55.642));
    --green: light-dark(oklch(60.3% 0.155 150.883), oklch(70.5% 0.154 153.814));
    --red: light-dark(oklch(62.1% 0.192 23.042), oklch(66.6% 0.18 21.433));
    --focus: light-dark(#387bff, #6ea0ff);
  }
}
/* With the switch, the stage keeps 44 px clear at its foot for it, and as much at its head, so
   the trace stays in the middle and a tall one (a narrow stage wrapping its thoughts) grows the
   stage instead of running under the switch. */
.root[data-switcher] {
  padding-block: 44px;
}

/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

:where(.root) button {
  padding: 0;
  font: inherit;
  letter-spacing: inherit;
  color: inherit;
  margin: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* The trace, up to 380 px wide. While open (and while the demo streams it in) it is at least
   176 px tall, so the header sits still as rows arrive. The hold comes and goes at once; as the
   trace shuts, the block follows its closing row back to the middle. */
.block {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 380px;
  min-width: 0;
}
.block[data-tall] {
  min-height: 176px;
}

/* The header: sparkle, words, chevron; its hover reaches 6 px past them. */
.header {
  display: flex;
  align-items: center;
  gap: 8px;
  width: fit-content;
  max-width: calc(100% + 12px);
  margin: 0 -6px;
  padding: 4px 6px;
  border-radius: 8px;
  transition: background-color 100ms var(--ease-ui);
}
@media (hover: hover) {
  .header:hover {
    background: var(--hover-2);
  }
}
.sparkle {
  flex-shrink: 0;
  color: var(--ink-3);
}
.chevron {
  flex-shrink: 0;
  color: var(--ink-3);
  transition: transform 300ms var(--ease-ui);
}
.header[aria-expanded='true'] .chevron {
  transform: rotate(180deg);
}
.status {
  display: contents;
}
.label,
.pending {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 13px;
  font-weight: 500;
  line-height: 19.5px;
}
.label {
  color: var(--ink-2);
  animation: fade 350ms ease-out both;
}

/* The shimmer: the words clipped to a background twice their width, muted with a bright band in
   the middle, which slides across them every 1.4 s. */
.pending,
.titleGoing,
.thoughtGoing {
  color: transparent;
  background-image: linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%);
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  animation: shimmer 1.4s linear infinite;
}

/* The trace opens by its grid row, 0fr to 1fr, and fades in with it. */
.panel {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition:
    grid-template-rows 400ms var(--ease),
    opacity 400ms var(--ease);
}
.panel[data-open] {
  grid-template-rows: 1fr;
  opacity: 1;
}
.clip {
  min-height: 0;
  overflow: hidden;
}
.trace {
  position: relative;
  margin: 4px 0 0 5px;
  padding-left: 16px;
}
/* The thread: from just under the sparkle down the rows, as tall as they are less 2 px. */
.thread {
  position: absolute;
  top: -8px;
  left: 3px;
  width: 1px;
  background: var(--line);
  transition: height 500ms var(--ease);
}

.rows {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 0;
  padding: 4px 0;
  list-style: none;
}
/* Each row rises 8 px and fades in; the component staggers them 120 ms apart. */
.rows > li {
  min-width: 0;
  animation: rise 320ms var(--ease) both;
}
.rows > .query {
  animation-duration: 300ms;
}
.rows > .more {
  animation: fade 300ms ease-out both;
}

.row {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  min-width: 0;
  min-height: 28px;
  padding: 2px 6px;
  border-radius: 6px;
  color: inherit;
  text-align: left;
  text-decoration: none;
}
.row[data-action] {
  transition: background-color 150ms var(--ease-ui);
}
@media (hover: hover) {
  .row[data-action]:hover {
    background: var(--hover);
  }
}
.row[aria-pressed='true'] {
  background: var(--inset);
}
.icon {
  flex-shrink: 0;
  color: var(--ink-3);
}
.title,
.titleGoing {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 12.5px;
  font-weight: 500;
  line-height: 18.75px;
}
.title {
  color: var(--ink);
}
.meta,
.code {
  /* On a narrow stage the site or the file gives way before the title does. */
  flex: 0 100 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 11.5px;
  line-height: 17.25px;
  color: var(--ink-3);
  white-space: nowrap;
}
.code {
  font-family: JetBrains, ui-monospace, 'SF Mono', monospace;
}

/* A thought: prose, wrapping, a step quieter than the steps. */
.thought,
.thoughtGoing {
  min-width: 0;
  font-size: 12.5px;
  line-height: 1.625;
}
.thought {
  color: var(--ink-2);
}

/* The search: its query, a row of its own; each page with a round badge; how many more. */
.query {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 24px;
  padding: 0 6px;
}
.queryText {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 12.5px;
  line-height: 18.75px;
  color: var(--ink-2);
}
.badge {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--blue);
  color: #fff;
}
.badge svg {
  flex-shrink: 0;
  width: 9px;
  height: 9px;
}
.badge[data-tint='orange'] {
  background: var(--orange);
}
.badge[data-tint='green'] {
  background: var(--green);
}
/* A page's title underlines from the left as the pointer comes over its row. */
.underline {
  position: relative;
}
.underline::after {
  content: '';
  position: absolute;
  right: 0;
  bottom: -1px;
  left: 0;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1);
}
@media (hover: hover) {
  .row[data-action]:hover .underline::after {
    transform: scaleX(1);
  }
}
.more {
  font-size: 12px;
  line-height: 18px;
  color: var(--ink-3);
}

/* An edit's lines, added and removed. */
.diff {
  flex-shrink: 0;
  font-family: JetBrains, ui-monospace, 'SF Mono', monospace;
  font-size: 11px;
  line-height: 16.5px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.added {
  color: var(--green);
}
.removed {
  color: var(--red);
}

/* The row still going: a 12 px ring with one bright quarter, a turn every 700 ms. */
.spinner {
  flex-shrink: 0;
  box-sizing: border-box;
  width: 12px;
  height: 12px;
  border: 1.5px solid var(--line-strong);
  border-top-color: var(--ink-2);
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}

/* The switch between kinds, floating over the stage's foot. */
.switch {
  position: absolute;
  bottom: 10px;
  left: 50%;
  display: flex;
  padding: 2px;
  border-radius: 999px;
  background: var(--field);
  transform: translateX(-50%);
}
.option {
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 11.5px;
  font-weight: 500;
  line-height: 17.25px;
  white-space: nowrap;
  color: var(--ink-3);
  transition:
    background-color 150ms var(--ease-ui),
    color 150ms var(--ease-ui),
    box-shadow 150ms var(--ease-ui);
}
@media (hover: hover) {
  .option:hover {
    color: var(--ink-2);
  }
}
.option[aria-checked='true'] {
  background: var(--surface);
  color: var(--ink);
  box-shadow:
    0 0 0 1px var(--button-ring),
    0 0 4px 0 var(--button-glow),
    0 1px 2px 0 var(--button-drop);
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}
@keyframes fade {
  from {
    opacity: 0;
  }
}
@keyframes shimmer {
  from {
    background-position: 150% center;
  }
  to {
    background-position: -50% center;
  }
}
@keyframes spin {
  to {
    transform: rotate(1turn);
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::after {
    transition: none !important;
    animation: none !important;
  }
  .pending,
  .titleGoing,
  .thoughtGoing {
    background: none;
    color: var(--ink-2);
  }
  .spinner {
    border-color: var(--ink-3);
  }
}
```

### thinking-trace/data.ts

```tsx
// 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 };

type Header = {
  /** What the header says once the work is done: "Thought for 4 seconds". */
  label: string;
  /** What it says while the work comes in, under a shimmer: "Thinking". */
  pendingLabel: string;
};

export type StepsTrace = Header & { steps: TraceStep[] };
export type ReasoningTrace = Header & { thoughts: string[] };
export type SearchTrace = Header & { query: string; sources: TraceSource[]; more?: number };
export type CodingTrace = Header & { tools: TraceTool[] };

export type Traces = {
  steps: StepsTrace;
  reasoning: ReasoningTrace;
  search: SearchTrace;
  coding: CodingTrace;
};

export const MODES: { id: TraceMode; name: string }[] = [
  { id: 'steps', name: 'Steps' },
  { id: 'reasoning', name: 'Reasoning' },
  { id: 'search', name: 'Search' },
  { id: 'coding', name: 'Coding' },
];

export const DEFAULT_TRACES: Traces = {
  steps: {
    label: 'Thought for 4 seconds',
    pendingLabel: 'Thinking',
    steps: [
      { label: 'Reading flavor briefs' },
      { label: 'Scanning supplier lists' },
      { label: 'Comparing tasting notes', meta: '6 flavors' },
      { label: 'Writing the scoop report' },
    ],
  },
  reasoning: {
    label: 'Thought for 4 seconds',
    pendingLabel: 'Thinking',
    thoughts: [
      'Summer demand spikes for stone-fruit flavors — peach and apricot lead.',
      'I should check cone inventory before promoting a waffle-bowl special.',
    ],
  },
  search: {
    label: 'Searched the web',
    pendingLabel: 'Searching the web',
    query: 'best waffle cone supplier',
    sources: [
      { title: 'Waffle Hollow', domain: 'wafflehollow.com', tint: 'blue' },
      { title: 'Copper Kettle Supply', domain: 'copperkettlesupply.com', tint: 'orange' },
      { title: 'The Cone Loft', domain: 'theconeloft.com', tint: 'green' },
    ],
    more: 7,
  },
  coding: {
    label: 'Ran 3 tools',
    pendingLabel: 'Running tools',
    tools: [
      { action: 'Read', target: 'flavors.ts' },
      { action: 'Edit', target: 'ChurnSchedule.tsx', added: 74, removed: 41 },
      { action: 'Run', target: 'make freeze' },
    ],
  },
};

/** How many rows a trace has, the search's query counted as one (its "+7 more" is not a row). */
export function rowCount(mode: TraceMode, traces: Traces) {
  switch (mode) {
    case 'steps':
      return traces.steps.steps.length;
    case 'reasoning':
      return traces.reasoning.thoughts.length;
    case 'search':
      return traces.search.sources.length + 1;
    case 'coding':
      return traces.coding.tools.length;
  }
}
```

### thinking-trace/icons.tsx

```tsx
// 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"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <polyline points="6 9 12 15 18 9" />
    </svg>
  );
}

/** A finished step. */
export function Check({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      width="14"
      height="14"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <polyline points="4 12 9 17 20 6" />
    </svg>
  );
}

/** The search's query. */
export function Magnifier({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      width="14"
      height="14"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <circle cx="11" cy="11" r="7" />
      <line x1="16.2" y1="16.2" x2="21" y2="21" />
    </svg>
  );
}

/** A web page, drawn white inside its source's round badge. */
export function Globe({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      width="9"
      height="9"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.5"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <line x1="3" y1="12" x2="21" y2="12" />
      <ellipse cx="12" cy="12" rx="4" ry="9" />
    </svg>
  );
}
```

### thinking-trace/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```