The one row behind reasoning, tool calls, sources and plans: an icon, a title and optional detail, with running, error and pending states, rows that expand, link or open a dialog, and shadcn's Trace API.
"use client"
import { ArrowUpRight, FileCode2, FilePen, Search, Terminal } from "lucide-react"
import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from "@/components/ballmac/trace"
export default function TraceDemo() {
return (
<Trace className="w-full max-w-md">
<TraceLine>
<TraceIcon>
<Search />
</TraceIcon>
<TraceTitle>Searched for minimumReleaseAge</TraceTitle>
</TraceLine>
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Terminal />
</TraceIcon>
<TraceTitle>Ran node --version</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<p className="ps-6 font-mono text-xs">v24.18.0</p>
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceLine>
<TraceIcon>
<FilePen />
</TraceIcon>
<TraceTitle>Edited pnpm-workspace.yaml</TraceTitle>
<TraceDescription>+3 −1</TraceDescription>
</TraceLine>
<a href="https://docs.astro.build" target="_blank" rel="noreferrer">
<TraceLine>
<TraceIcon>
<FileCode2 />
</TraceIcon>
<TraceTitle>
Read the Astro guide
<span className="sr-only"> (opens in a new tab)</span>
</TraceTitle>
<TraceIcon>
<ArrowUpRight className="rtl:-scale-x-100" />
</TraceIcon>
</TraceLine>
</a>
<TraceLine status="running">
<TraceIcon>
<Terminal />
</TraceIcon>
<TraceTitle>Running pnpm build</TraceTitle>
</TraceLine>
</Trace>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/traceInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/shimmer-textCopy the source into your project.
components/ballmac/trace.tsx// Ballmac UI: Trace. https://ui.ballmac.com/components/trace // Based on shadcn/ui's Trace (MIT, Copyright (c) 2023 shadcn): the same parts and props, restyled with tokens, a shimmer that follows the theme, and no Radix UI. "use client" import * as React from "react" import { ChevronRight } from "lucide-react" import { ShimmerText } from "@/components/ballmac/shimmer-text" import { cn } from "@/lib/utils" type TraceStatus = "running" | "error" | "pending" const StatusContext = React.createContext<TraceStatus | undefined>(undefined) /** A list of rows. It also styles any trigger or link that wraps a `TraceLine`, so a row looks the same whatever it does. */ function Trace({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="trace" className={cn( "flex w-full min-w-0 flex-col text-sm", "*:has-[>[data-slot=trace-line]]:flex *:has-[>[data-slot=trace-line]]:w-fit *:has-[>[data-slot=trace-line]]:max-w-full *:has-[>[data-slot=trace-line]]:min-w-0 *:has-[>[data-slot=trace-line]]:text-start *:has-[>[data-slot=trace-line]]:outline-none", "*:has-[>[data-slot=trace-line]]:rounded-md *:has-[>[data-slot=trace-line]]:focus-visible:ring-[3px] *:has-[>[data-slot=trace-line]]:focus-visible:ring-ring/50", className )} {...props} /> ) } type TraceLineProps = React.ComponentProps<"span"> & { /** The state of the work the line reports. Say it in the title too: color and motion alone are not read out. */ status?: TraceStatus } /** One line: an icon and a title. It is a span, so it is not a button or a tab stop: wrap it in a trigger or a link to make it do something. */ function TraceLine({ className, status, ...props }: TraceLineProps) { return ( <StatusContext.Provider value={status}> <span data-slot="trace-line" data-status={status} className={cn( "flex w-fit min-w-0 max-w-full items-center gap-2 py-1 text-muted-foreground transition-colors motion-reduce:transition-none", "in-[a,button]:cursor-pointer in-[a:hover,button:hover]:text-foreground", "data-[status=error]:text-destructive data-[status=error]:in-[a:hover,button:hover]:text-destructive data-[status=pending]:opacity-60", className )} {...props} /> </StatusContext.Provider> ) } /** A decorative icon slot, hidden from assistive technology. Before the title it names the action; after it, it is the indicator. */ function TraceIcon({ className, ...props }: React.ComponentProps<"span">) { return <span data-slot="trace-icon" aria-hidden="true" className={cn("flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", className)} {...props} /> } /** One sentence on one line. It truncates, and shimmers while the line is running (and holds still when motion is reduced). */ function TraceTitle({ className, children, ...props }: React.ComponentProps<"span">) { const status = React.useContext(StatusContext) return ( <span data-slot="trace-title" className={cn("min-w-0 truncate", className)} {...props}> {status === "running" && typeof children === "string" ? <ShimmerText className="max-w-full truncate align-bottom">{children}</ShimmerText> : children} </span> ) } /** Secondary text after the title, such as a count or a duration. It does not truncate. */ function TraceDescription({ className, ...props }: React.ComponentProps<"span">) { return <span data-slot="trace-description" className={cn("shrink-0 tabular-nums text-muted-foreground/70", className)} {...props} /> } // ------------------------------------------------------------------------------------------------- the row that expands type CollapsibleState = { open: boolean; setOpen: (open: boolean) => void; contentId: string; triggerId: string; disabled: boolean } const CollapsibleContext = React.createContext<CollapsibleState | null>(null) const useRow = () => { const c = React.useContext(CollapsibleContext) if (!c) throw new Error("TraceCollapsibleTrigger and TraceCollapsibleContent must be used inside <TraceCollapsible>.") return c } type TraceCollapsibleProps = Omit<React.ComponentProps<"div">, "defaultValue"> & { /** The controlled open state. Pass it with `onOpenChange`. */ open?: boolean /** Whether the row starts open. */ defaultOpen?: boolean onOpenChange?: (open: boolean) => void disabled?: boolean } /** A row that expands. It takes the props of a collapsible: `open`, `defaultOpen`, `onOpenChange` and `disabled`. */ function TraceCollapsible({ open: openProp, defaultOpen = false, onOpenChange, disabled = false, className, children, ...props }: TraceCollapsibleProps) { const [inner, setInner] = React.useState(defaultOpen) const open = openProp ?? inner const id = React.useId() const setOpen = React.useCallback( (next: boolean) => { if (openProp === undefined) setInner(next) onOpenChange?.(next) }, [openProp, onOpenChange] ) const value = React.useMemo(() => ({ open, setOpen, contentId: `${id}-content`, triggerId: `${id}-trigger`, disabled }), [open, setOpen, id, disabled]) return ( <CollapsibleContext.Provider value={value}> <div data-slot="trace-collapsible" data-state={open ? "open" : "closed"} className={cn("flex w-full min-w-0 flex-col", className)} {...props}> {children} </div> </CollapsibleContext.Provider> ) } type TraceCollapsibleTriggerProps = Omit<React.ComponentProps<"button">, "type"> /** The button that opens and closes the row. Put a `TraceLine` inside it; it adds a chevron after the line. */ function TraceCollapsibleTrigger({ className, children, onClick, ...props }: TraceCollapsibleTriggerProps) { const row = useRow() return ( <button type="button" id={row.triggerId} data-slot="trace-collapsible-trigger" data-state={row.open ? "open" : "closed"} aria-expanded={row.open} aria-controls={row.contentId} disabled={row.disabled} onClick={(e) => { onClick?.(e) if (!e.defaultPrevented) row.setOpen(!row.open) }} className={cn( "flex w-fit min-w-0 max-w-full cursor-pointer items-center gap-2 rounded-md text-start text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 has-[[data-status=error]]:text-destructive motion-reduce:transition-none", className )} {...props} > {children} <TraceIcon className="-ms-1"> <ChevronRight className={cn("transition-transform duration-200 motion-reduce:transition-none", row.open ? "rotate-90" : "rtl:rotate-180")} /> </TraceIcon> </button> ) } type TraceCollapsibleContentProps = React.ComponentProps<"div"> /** The detail of a row that expands. Closed, it is out of the tab order and hidden from screen readers. Its text is muted: give blocks inside it `text-foreground`. */ function TraceCollapsibleContent({ className, children, ...props }: TraceCollapsibleContentProps) { const row = useRow() return ( <div id={row.contentId} role="region" aria-labelledby={row.triggerId} data-slot="trace-collapsible-content" data-state={row.open ? "open" : "closed"} inert={!row.open} aria-hidden={!row.open || undefined} className={cn("grid min-w-0 transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none", row.open ? "grid-rows-[1fr]" : "grid-rows-[0fr]")} > <div className="min-h-0 overflow-hidden"> <div data-slot="trace-collapsible-content-inner" className={cn("min-w-0 break-words py-1 text-muted-foreground", className)} {...props}> {children} </div> </div> </div> ) } export { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle, type TraceCollapsibleContentProps, type TraceCollapsibleProps, type TraceCollapsibleTriggerProps, type TraceLineProps, type TraceStatus, }Update the import paths to match your project setup.
Usage
import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from "@/components/ballmac/trace"The full example is in the Code tab above.
Examples
Running, error and pending
import { CircleAlert, CircleDashed, Loader, Terminal } from "lucide-react"
import { Trace, TraceDescription, TraceIcon, TraceLine, TraceTitle } from "@/components/ballmac/trace"
export default function TraceStatus() {
return (
<Trace className="w-full max-w-md">
<TraceLine>
<TraceIcon>
<Terminal />
</TraceIcon>
<TraceTitle>Ran pnpm install</TraceTitle>
<TraceDescription>2.4s</TraceDescription>
</TraceLine>
<TraceLine status="running">
<TraceIcon>
<Loader className="animate-spin motion-reduce:animate-none" />
</TraceIcon>
<TraceTitle>Running pnpm build</TraceTitle>
</TraceLine>
<TraceLine status="error">
<TraceIcon>
<CircleAlert />
</TraceIcon>
<TraceTitle>Failed to run pnpm lint</TraceTitle>
<TraceDescription>exit 1</TraceDescription>
</TraceLine>
<TraceLine status="pending">
<TraceIcon>
<CircleDashed />
</TraceIcon>
<TraceTitle>Run pnpm test</TraceTitle>
</TraceLine>
</Trace>
)
}Opens while it streams
The lockfile pins 9.1.0, so the new option is not available yet. I will check the changelog before changing the setting.
pnpm 9.1.0 └── (not found)
"use client"
import * as React from "react"
import { Brain, Terminal } from "lucide-react"
import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle } from "@/components/ballmac/trace"
const thought = "The lockfile pins 9.1.0, so the new option is not available yet. I will check the changelog before changing the setting."
/** A row that opens while it streams and closes when it is done, unless the reader has already chosen. Pass `open ?? running`. */
export default function TraceCollapsibleExample() {
const [running, setRunning] = React.useState(true)
const [open, setOpen] = React.useState<boolean>()
React.useEffect(() => {
const t = setTimeout(() => setRunning(false), 3500)
return () => clearTimeout(t)
}, [])
return (
<Trace className="w-full max-w-md">
<TraceCollapsible open={open ?? running} onOpenChange={setOpen}>
<TraceCollapsibleTrigger>
<TraceLine status={running ? "running" : undefined}>
<TraceIcon>
<Brain />
</TraceIcon>
<TraceTitle>{running ? "Thinking" : "Thought for 4 seconds"}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<p className="ps-6">{thought}</p>
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceCollapsible defaultOpen>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Terminal />
</TraceIcon>
<TraceTitle>Ran pnpm why minimum-release-age</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<pre className="ms-6 overflow-x-auto rounded-md border bg-muted p-2 font-mono text-xs text-foreground" dir="ltr">{"pnpm 9.1.0\n└── (not found)"}</pre>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
)
}Chain of thought
"use client"
import { Check, ListChecks, LoaderCircle } from "lucide-react"
import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from "@/components/ballmac/trace"
/** A chain of thought: a summary row that expands into the steps behind it. Each level is a Trace. */
export default function TraceSteps() {
return (
<Trace className="w-full max-w-md">
<TraceCollapsible defaultOpen>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<ListChecks />
</TraceIcon>
<TraceTitle>Planned the migration</TraceTitle>
<TraceDescription>3 of 4 steps</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent className="ps-4">
<Trace>
<TraceLine>
<TraceIcon>
<Check />
</TraceIcon>
<TraceTitle>Read the old schema</TraceTitle>
</TraceLine>
<TraceLine>
<TraceIcon>
<Check />
</TraceIcon>
<TraceTitle>Wrote the new tables</TraceTitle>
</TraceLine>
<TraceLine>
<TraceIcon>
<Check />
</TraceIcon>
<TraceTitle>Backfilled 12,408 rows</TraceTitle>
</TraceLine>
<TraceLine status="running">
<TraceIcon>
<LoaderCircle className="animate-spin motion-reduce:animate-none" />
</TraceIcon>
<TraceTitle>Switching reads to the new tables</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
)
}Sources
import { ArrowUpRight, Globe } from "lucide-react"
import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from "@/components/ballmac/trace"
const sources = [
{ title: "Dynamic search", host: "ui.shadcn.com", href: "https://ui.shadcn.com/docs/registry/dynamic-search" },
{ title: "Registry index", host: "ui.shadcn.com", href: "https://ui.shadcn.com/docs/registry/registry-index" },
{ title: "Base UI", host: "base-ui.com", href: "https://base-ui.com" },
]
/** The links an answer used: a row that expands into a Trace of link rows. */
export default function TraceSources() {
return (
<Trace className="w-full max-w-md">
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Globe />
</TraceIcon>
<TraceTitle>Used 3 sources</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent className="ps-4">
<Trace>
{sources.map((s) => (
<a key={s.href} href={s.href} target="_blank" rel="noreferrer">
<TraceLine>
<TraceTitle>
{s.title}
<span className="sr-only"> (opens in a new tab)</span>
</TraceTitle>
<TraceDescription>{s.host}</TraceDescription>
<TraceIcon>
<ArrowUpRight className="rtl:-scale-x-100" />
</TraceIcon>
</TraceLine>
</a>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
)
}Opens a dialog
import { ChevronRight, Search } from "lucide-react"
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ballmac/dialog"
import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ballmac/trace"
/** A line that opens a dialog: wrap the TraceLine in the dialog's own trigger, and the dialog keeps its focus handling. */
export default function TraceDialog() {
return (
<Trace className="w-full max-w-md">
<Dialog>
<DialogTrigger>
<TraceLine>
<TraceIcon>
<Search />
</TraceIcon>
<TraceTitle>Searched the web for cache keys</TraceTitle>
<TraceIcon>
<ChevronRight className="rtl:rotate-180" />
</TraceIcon>
</TraceLine>
</DialogTrigger>
<DialogContent>
<DialogTitle>Searched the web for cache keys</DialogTitle>
<DialogDescription>Three queries, eleven results read.</DialogDescription>
<ul className="list-disc space-y-1 ps-5 text-sm">
<li>cache key strategies for static builds</li>
<li>content hash vs version in the key</li>
<li>invalidate a CDN cache on deploy</li>
</ul>
</DialogContent>
</Dialog>
</Trace>
)
}An agent turn with a question
The totals ignore quantity, and one tax rate is missing. Which should I fix first?
"use client"
import { FileSearch, Terminal } from "lucide-react"
import { CodeBlock, CodeBlockContent } from "@/components/ballmac/code-block"
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireError, QuestionnaireItem, QuestionnaireSubmit, QuestionnaireTitle } from "@/components/ballmac/questionnaire"
import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from "@/components/ballmac/trace"
/** One agent turn built from the three shadcn chat-kit parts: what it did, what it ran, and a question it needs answered. */
export default function TraceAgentTurn() {
return (
<div className="grid w-full max-w-lg gap-4 text-sm">
<Trace>
<TraceLine>
<TraceIcon>
<FileSearch />
</TraceIcon>
<TraceTitle>Read 4 files</TraceTitle>
<TraceDescription>src/billing</TraceDescription>
</TraceLine>
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Terminal />
</TraceIcon>
<TraceTitle>Ran the billing tests</TraceTitle>
<TraceDescription>2 failed</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm" variant="destructive">
<CodeBlockContent wrap className="max-h-48">
{"FAIL invoice.test.ts\n Expected total 40, received 20\nFAIL tax.test.ts\n Rate for DE is missing"}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
<p>The totals ignore quantity, and one tax rate is missing. Which should I fix first?</p>
<Questionnaire onSubmit={(e) => e.preventDefault()}>
<QuestionnaireItem name="first" required>
<QuestionnaireTitle className="text-base">Fix first</QuestionnaireTitle>
<QuestionnaireChoices>
<QuestionnaireChoice value="totals">Invoice totals</QuestionnaireChoice>
<QuestionnaireChoice value="tax">The missing tax rate</QuestionnaireChoice>
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnaireSubmit>Send</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
</div>
)
}API reference
<TraceLine>
| Prop | Type | Default |
|---|---|---|
statusThe state of the work the line reports. Say it in the title too: color and motion alone are not read out. | TraceStatus | — |
<TraceCollapsible>
| Prop | Type | Default |
|---|---|---|
openThe controlled open state. Pass it with `onOpenChange`. | boolean | — |
defaultOpenWhether the row starts open. | boolean | false |
onOpenChange | (open: boolean) => void | — |
disabled | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | On a TraceCollapsibleTrigger, opens or closes the row (aria-expanded and aria-controls are set) |
| Tab | A TraceLine on its own is text and not a tab stop; the trigger, link or dialog trigger around it is |
| Screen readers | Icons are hidden, so the title is the name: write a sentence (Edited trace.tsx) and put the state in it (Running pnpm build). Closed content is inert |
| Reduced motion | The shimmer and the chevron turn stop; the title still says what is happening |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 7 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (4% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
10 tab stops, each showing a visible change when it takes focus. 4 documented keys.
Not applicable: Loading, empty and error
A row shows the state of work reported to it (running, error, pending); it loads and fetches nothing itself.
Passed: Automated tests
Covered by trace.test.tsx.
For information: Footprint
2.6 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<Trace> holds rows. A row is <TraceLine status><TraceIcon/><TraceTitle/><TraceDescription/></TraceLine>. Wrap it in <TraceCollapsibleTrigger> inside a <TraceCollapsible> to expand, or in a link, or a dialog's trigger. The parts and props match shadcn's Trace. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Trace (@ballmac/trace) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing what an AI agent did: reasoning, tool calls, searches, file edits
- Plans, task lists and sources where every row should look the same
- Rows that expand, link out or open a dialog or sheet
Not for
- The agent's reply text; use message or ai-message
- A single tool call with its full input and output; use tool-call-card
- A general disclosure; use collapsible or accordion
Registry JSON: https://ui.ballmac.com/r/trace.json
Credits
Based on shadcn/ui Trace, adapted by Ballmac. Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Code Block
A code panel with filename and language header, line numbers, highlighted lines, a wrap toggle, copy feedback and file tabs. No highlighter bundled; pass Shiki output as children.
Questionnaire
A multi-step questionnaire with single-choice, multiple-choice, freeform and skippable questions, answer shortcuts, validation and native form data, built on shadcn's headless Questionnaire so the API is the same.
Dialog
A Radix modal dialog with a blurred overlay, centered card surface that fits 360 px screens, header and footer parts, optional close button and animations.
Shimmer Text
Text with a band of light sweeping across it, from muted to full foreground color. Suited to AI "thinking" and loading status lines; static under reduced motion.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.