A honeycomb of frosted glass with one tinted tile that travels to whatever you pick, an action, and the queue of runs it dispatches.
Tool Trace
A run log for agent tool calls: the step in flight opens itself, writes its output a line at a time, then folds away and goes still.
ToolTrace is what one run looks like from the inside — every tool the agent reached for, in order, and what came back. Pass the steps with a state on each and it draws a rail through them: dashed markers for what has not happened yet, tinted ones for what has, a hairline filling behind each step as it resolves. The motion is spent on the step in flight and nowhere else. It opens as it starts, its console fills a line at a time, and the moment it lands the panel folds away and hands the rail to the next tool — so an unattended trace always shows the work that is actually happening without the reader touching anything. Every other step is a still drawing. Open one yourself and follow yields for good, because a reader who went looking should not have the page close under them. States carry a glyph and a spoken label as well as a hue, transitions are announced through a single polite live region rather than by streaming the console at a screen reader, and the whole thing resolves instantly under prefers-reduced-motion.
Preview
Press play. Each tool opens itself as it starts, writes its output a line at a time, then folds away and hands the rail to the next one — until one of them fails and the rest are skipped.
- open_pull_requestfeat/tool-trace → main, Queued
iconAt rest the trace is a still drawing. A glyph per tool replaces the state marker while the ring, the rail and the hidden label keep carrying the outcome — and the step that failed is the one left open.
- read_filelib/registry/components.ts, Done0.3s
- grep_searchpattern: "defineComponent\(", Done0.4s
- edit_fileregistry/components/tool-trace.tsx, Done1.1s
- ✗ lint tool-trace.tsx:284 react-hooks/exhaustive-deps→ 1 error, exit 1
- open_pull_requestfeat/tool-trace → main, Skipped
multiple={false}Opening a step folds the last one shut, which keeps a long retrieval chain to one screen. detail takes markup, so a step with nothing to print still has somewhere to explain itself.
- 0.91 docs/registry-setup.mdx0.88 docs/installation.mdx0.74 docs/theming.mdx0.61 docs/ai.mdx
Installation
Install Tool Trace with the shadcn CLI. The registry item resolves its own dependencies and writes the file to components/joinui/tool-trace.tsx.
pnpm dlx shadcn@latest add @joinui/tool-traceUsage
import { ToolTrace } from "@/components/joinui/tool-trace"
export function Example() {
return (
<ToolTrace
label="Agent run"
steps={[
{
id: "read",
name: "read_file",
summary: "lib/registry/components.ts",
state: "done",
meta: "0.3s",
output: ["→ 1096 lines, 38.2 kB"],
},
{
id: "test",
name: "run_tests",
summary: "pnpm check",
state: "running",
output: ["✓ typecheck", "✓ lint"],
},
{ id: "pr", name: "open_pull_request" },
]}
/>
)
}Props
ToolTrace
| Prop | Type | Default | Description |
|---|---|---|---|
| steps (required) | ToolTraceStep[] | — | The tool calls to render, in order. |
| label | string | "Run" | Header eyebrow, and the accessible name of the list when the header is hidden. |
| status | string | — | Overrides the header chip copy, which is otherwise rolled up from the steps. |
| variant | "card" | "plain" | "card" | Plain drops the surrounding rule, background and padding so the trace can sit inside your own container. |
| showHeader | boolean | true | Renders the eyebrow and status chip above the steps. |
| size | "sm" | "md" | "md" | Marker, type and spacing scale. |
| expanded | string[] | — | Controlled disclosure — the ids of the open steps. Passing it hands the whole disclosure over to you, and turns follow off. |
| defaultExpanded | string[] | — | Where an uncontrolled trace starts. Falls back to the steps that set defaultOpen. |
| onExpandedChange | (expanded: string[]) => void | — | Fires with the new set when the reader opens or closes a step. Follow does not fire it — what it opens is derived from the steps you already own. |
| multiple | boolean | true | Allows more than one step to be open at a time. Off, the trace behaves like an accordion. |
| follow | boolean | true | Opens the step that starts running and closes it again when it resolves. Yields permanently on any step the reader opens or closes themselves, and is ignored while expanded is controlled. |
| stream | boolean | true | Reveals a running step's output a line at a time. Only a step that starts running after mount streams; one that was already running when the page loaded renders whole. |
| lineSpeed | number | 90 | Milliseconds per revealed line. |
| maxConsoleHeight | string | "11rem" | Height the console scrolls within. Any CSS length. |
| announce | boolean | true | Announces each step's state change through a polite live region. Turn it off when several traces share a page. |
| footer | React.ReactNode | — | Content placed below a rule — an action, a note, a summary row. |
| className | string | — | Merged onto the root element through `cn`. |
ToolTraceStep
Shape of a single entry in the `steps` array.
| Prop | Type | Default | Description |
|---|---|---|---|
| id (required) | string | — | Stable identity for the rendered list item, and the value reported by onExpandedChange. |
| name (required) | string | — | The tool that ran. Set in mono, because it is an identifier. |
| summary | string | — | Its arguments in one line, set beside the name in the interface face. |
| state | "pending" | "running" | "done" | "failed" | "skipped" | "pending" | Picks the hue and the glyph: blue while running, green when it lands, red when it does not, grey for a step that is queued or was skipped. |
| meta | string | — | Trailing meta, set in mono with tabular figures — a duration, a token count. |
| detail | React.ReactNode | — | Markup revealed under the row, above the console. A step with a detail or an output is the only kind that can be opened. |
| output | string[] | — | The console body, one entry per line. Revealed a line at a time while the step is running. |
| icon | React.ReactNode | — | Replaces the state glyph inside the marker. The ring, the rail and the hidden label keep carrying the state. Sized by the component, so pass a bare icon element. |
| defaultOpen | boolean | — | Opens the step on first render. |
Dependencies
npm packages
- motion
- lucide-react
Registry items
- utils
Installed automatically by the CLI when they are missing.
Accessibility
- Steps render as an ordered list, so assistive technology reports both position and total.
- The step in flight carries `aria-current="step"`.
- Only a step with a `detail` or an `output` becomes a button; one with nothing to show stays a plain row, which keeps the tab order down to the steps worth opening.
- Each toggle carries `aria-expanded` and `aria-controls`, and the panel it opens is a `region` named by it — the WAI accordion pattern, including the optional arrow-key navigation between headers.
- Colour is never the only signal, which is what satisfies WCAG 1.4.1: every state also has its own glyph — a spinner, a tick, a cross, a dash, a pip — and appends a visually hidden label reading “Running”, “Done”, “Failed”, “Skipped” or “Queued”.
- A failed step additionally shifts its name to the critical hue and tints its console, so the failure is findable without opening every panel.
- State changes are announced once each through a single polite live region (`announce`), and the console itself is not live — a log writing itself line by line is unreadable through a screen reader, but “run_tests, Failed” is exactly what someone following the run needs.
- The console scrolls to its newest line only while the reader is already at the bottom, so reading back through output is never yanked forward.
- Nothing animates on mount: a trace rendered with a step already running shows that step open and its output whole, rather than replaying a run that happened before the page loaded.
- The one loop in the component — the marker's pulse and the caret — runs only while a tool is genuinely in flight, and stops when it resolves.
- Under `prefers-reduced-motion` every transition resolves at zero duration: panels open instantly, output arrives whole, and neither the pulse nor the caret runs.
- Both themes are covered by the tokens rather than by `dark:` variants, so the component keeps its contrast inside a forced-theme subtree.
Keyboard interactions
| Key | Behaviour |
|---|---|
| EnterSpace | Opens or closes the focused step, and pins it against `follow`. |
| ↓ | Moves the focus to the next step that can be opened. |
| ↑ | Moves the focus to the previous step that can be opened. |
| HomeEnd | Jumps to the first or last step that can be opened. |
| Tab | Leaves the trace. Steps with nothing to show are skipped entirely, and an open panel adds nothing of its own unless you put a control in `detail`. |
Customization
Drive it from a real run
The trace is stateless — it draws whatever the steps say — so streaming one is a matter of mapping your events onto states. Everything past a failure reads better as skipped than as queued, because those tools are never going to run.
const steps = plan.map((tool, index) => ({
id: tool.id,
name: tool.name,
summary: tool.args,
meta: tool.duration,
output: tool.stdout,
state:
index < cursor
? tool.error
? "failed"
: "done"
: failed
? "skipped"
: index === cursor
? "running"
: "pending",
}))
<ToolTrace label="Agent run" steps={steps} />One panel at a time
Off, multiple turns the trace into an accordion: opening a step folds the last one shut, which keeps a long chain of retrieval calls to a single screen.
<ToolTrace
label="Retrieval"
size="sm"
multiple={false}
defaultExpanded={["search"]}
steps={steps}
/>Hold the panels open yourself
Pass expanded and the disclosure is entirely yours — follow steps aside, and nothing opens or closes unless you say so. Useful when the open step has to survive a re-mount, or when it belongs in the URL.
const [open, setOpen] = React.useState<string[]>(["test"])
<ToolTrace
label="Agent run"
steps={steps}
expanded={open}
onExpandedChange={setOpen}
/>Let it sit still
A finished run is a document, not a performance. Turn follow and stream off and every step renders exactly as its state says, which is what you want for a trace loaded from history.
<ToolTrace
label="Run 4812"
steps={steps}
follow={false}
stream={false}
defaultExpanded={steps.filter((step) => step.state === "failed").map((s) => s.id)}
/>Give each tool its own glyph
An icon replaces the state glyph while the ring, the rail and the hidden state label keep carrying the meaning. The component sizes it, so pass the element bare.
import { FileText, PenLine, Search, TestTube } from "lucide-react"
<ToolTrace
label="Run 4812"
steps={[
{ id: "read", name: "read_file", state: "done", icon: <FileText /> },
{ id: "grep", name: "grep_search", state: "done", icon: <Search /> },
{ id: "edit", name: "edit_file", state: "done", icon: <PenLine /> },
{ id: "test", name: "run_tests", state: "failed", icon: <TestTube /> },
]}
/>Retint the states, and the console
The states read the component palette rather than literal colours, so a brand hue is a token override — no props to thread and no variants to add. The console surface is a wash of the theme's own ink over the card, which is what lets one declaration cover both themes; redeclare it only if you want a console that is a surface in its own right.
/* app/globals.css */
:root,
.light {
/* A violet running state instead of the default blue. */
--info: oklch(0.5 0.19 292);
--info-soft: oklch(0.965 0.025 292);
--info-foreground: oklch(0.99 0.005 292);
/* Any CSS colour. The default is 4% of the foreground over whatever is behind it. */
--tool-trace-console: oklch(0.21 0.011 48 / 0.04);
}
.dark {
--info: oklch(0.76 0.15 292);
--info-soft: oklch(0.255 0.06 292);
--info-foreground: oklch(0.16 0.04 292);
--tool-trace-console: oklch(0 0 0 / 0.25);
}Related components
An inline request from an agent to run a command: the tool, the command, a clock that holds while you read, and two keys to answer with.
A colour-coded step tracker for orders, deployments and onboarding, still at rest and animated only when a step advances.