Embossed UI

Search documentation

Search for a page or component.

Trace

The building block for reasoning, tool calls, sources and more.

The deploy is slow because image optimization runs on every build. The cache key includes the commit hash, so it never hits.
Searched for cacheKey3 resultsRead the Vercel build cache docsEdited next.config.ts+4 −1Running pnpm build
import {
  BrainIcon,
  FilePenIcon,
  GlobeIcon,
  SearchIcon,
  TerminalIcon,
} from "lucide-react"

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

export function TraceDemo() {
  return (
    <Trace className="max-w-md py-8">
      <TraceCollapsible defaultOpen>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <BrainIcon />
            </TraceIcon>
            <TraceTitle>Thought for 6 seconds</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          The deploy is slow because image optimization runs on every build. The
          cache key includes the commit hash, so it never hits.
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceLine>
        <TraceIcon>
          <SearchIcon />
        </TraceIcon>
        <TraceTitle>Searched for cacheKey</TraceTitle>
        <TraceDescription>3 results</TraceDescription>
      </TraceLine>
      <TraceLine>
        <TraceIcon>
          <GlobeIcon />
        </TraceIcon>
        <TraceTitle>Read the Vercel build cache docs</TraceTitle>
      </TraceLine>
      <TraceLine>
        <TraceIcon>
          <FilePenIcon />
        </TraceIcon>
        <TraceTitle>Edited next.config.ts</TraceTitle>
        <TraceDescription>+4 −1</TraceDescription>
      </TraceLine>
      <TraceLine status="running">
        <TraceIcon>
          <TerminalIcon />
        </TraceIcon>
        <TraceTitle>Running pnpm build</TraceTitle>
      </TraceLine>
    </Trace>
  )
}

Trace is a list of lines. Each line has an icon, a title, and an optional description and status. A line can expand to show more detail, link to a page, or open a dialog or sheet.

Every line has the same shape, so reasoning, tool calls, sources and plans built from Trace look and behave the same.

Rows are quiet muted-foreground text that goes to foreground on hover. Interactive rows take the 2px focus ring with a 2px offset, the chevron turns over in 200ms, and an expanded row's detail hangs off the engraved Separator line, centered under the icon.

Use Trace for the steps an agent takes, like calling a tool, running a subagent or editing a file. Use Message for its reply, and Marker for notes and dividers between turns.

Installation

pnpm dlx shadcn@latest add https://ui.ikramhasan.com/r/trace.json

Usage

Use a TraceLine for a single step:

import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace"
<Trace>
  <TraceLine>
    <TraceIcon>
      <SearchIcon />
    </TraceIcon>
    <TraceTitle>Searched for minimumReleaseAge</TraceTitle>
  </TraceLine>
</Trace>

Wrap the line in a TraceCollapsible to show more detail when it expands:

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"
<Trace>
  <TraceCollapsible>
    <TraceCollapsibleTrigger>
      <TraceLine>
        <TraceIcon>
          <TerminalIcon />
        </TraceIcon>
        <TraceTitle>Ran node --version</TraceTitle>
      </TraceLine>
    </TraceCollapsibleTrigger>
    <TraceCollapsibleContent>v24.18.0</TraceCollapsibleContent>
  </TraceCollapsible>
</Trace>

Composition

Use the following composition to build a trace line:

Trace
└── TraceLine
    ├── TraceIcon
    ├── TraceTitle
    ├── TraceDescription
    └── TraceIcon

Wrap the line in a TraceCollapsible to show more detail when it expands:

Trace
└── TraceCollapsible
    ├── TraceCollapsibleTrigger
    │   └── TraceLine
    └── TraceCollapsibleContent

TraceLine is always the line. To make a line do something, wrap it in that thing's trigger.

Features

  • One row for reasoning, tool calls, sources, plans and tasks
  • Rows that expand, link out, or open a dialog, sheet, drawer or popover
  • running, error and pending states, with a shimmer while running
  • Nested rows, so a summary expands into steps and a step into its output
  • Works with reasoning, tool and source parts from the AI SDK
  • Keyboard and screen reader support from the trigger that wraps each row
  • Customizable styling through the className prop on every part

Trace Line

Use a TraceLine on its own for a step with no detail to show. It reads as plain text.

Searched for minimumReleaseAge
import { SearchIcon } from "lucide-react"

import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace"

export function TraceStatic() {
  return (
    <Trace className="max-w-md">
      <TraceLine>
        <TraceIcon>
          <SearchIcon />
        </TraceIcon>
        <TraceTitle>Searched for minimumReleaseAge</TraceTitle>
      </TraceLine>
    </Trace>
  )
}

Status

Set status on TraceLine when a step is running, has failed or hasn't started yet.

Ran pnpm installRunning pnpm buildFailed to run pnpm lintRun pnpm test
import { CircleDashedIcon, TerminalIcon } from "lucide-react"

import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace"

export function TraceStatus() {
  return (
    <Trace className="max-w-md">
      <TraceLine>
        <TraceIcon>
          <TerminalIcon />
        </TraceIcon>
        <TraceTitle>Ran pnpm install</TraceTitle>
      </TraceLine>
      <TraceLine status="running">
        <TraceIcon>
          <TerminalIcon />
        </TraceIcon>
        <TraceTitle>Running pnpm build</TraceTitle>
      </TraceLine>
      <TraceLine status="error">
        <TraceIcon>
          <TerminalIcon />
        </TraceIcon>
        <TraceTitle>Failed to run pnpm lint</TraceTitle>
      </TraceLine>
      <TraceLine status="pending">
        <TraceIcon>
          <CircleDashedIcon />
        </TraceIcon>
        <TraceTitle>Run pnpm test</TraceTitle>
      </TraceLine>
    </Trace>
  )
}
StatusUse for
noneWork that is done: "Ran pnpm install".
"error"Work that failed. Say so in the title: "Failed to run pnpm lint".
"running"Work in progress. Write the title in the present tense: "Running pnpm build".
"pending"Work that has not started, such as a plan step: "Run pnpm test".

Description

Put secondary text such as a diff stat, a duration or a count in a TraceDescription after the TraceTitle. It does not truncate, and its figures are tabular.

Edited components/ui/trace.tsx+12 −4Searched the codebase for useChat8 filesWaited for the deploy1m 42s
import { ClockIcon, FilePenIcon, SearchIcon } from "lucide-react"

import {
  Trace,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

export function TraceDescriptionExample() {
  return (
    <Trace className="max-w-md">
      <TraceLine>
        <TraceIcon>
          <FilePenIcon />
        </TraceIcon>
        <TraceTitle>Edited components/ui/trace.tsx</TraceTitle>
        <TraceDescription>+12 −4</TraceDescription>
      </TraceLine>
      <TraceLine>
        <TraceIcon>
          <SearchIcon />
        </TraceIcon>
        <TraceTitle>Searched the codebase for useChat</TraceTitle>
        <TraceDescription>8 files</TraceDescription>
      </TraceLine>
      <TraceLine>
        <TraceIcon>
          <ClockIcon />
        </TraceIcon>
        <TraceTitle>Waited for the deploy</TraceTitle>
        <TraceDescription>1m 42s</TraceDescription>
      </TraceLine>
    </Trace>
  )
}

Collapsible

Wrap the line in a TraceCollapsible to show more detail when it expands. Put the TraceLine inside TraceCollapsibleTrigger and the detail in TraceCollapsibleContent.

import { TerminalIcon } from "lucide-react"

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

export function TraceCollapsibleExample() {
  return (
    <Trace className="max-w-md">
      <TraceCollapsible>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <TerminalIcon />
            </TraceIcon>
            <TraceTitle>Ran node --version</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>v24.18.0</TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  )
}

Open State

Rows start closed. Use defaultOpen on TraceCollapsible to start open, or open with onOpenChange to control a row.

Read the build log, find the slow step, then fix its cache key.
"use client"

import * as React from "react"
import { BrainIcon, ListChecksIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

export function TraceOpen() {
  const [open, setOpen] = React.useState(false)

  return (
    <div className="flex w-full max-w-md flex-col items-start gap-6">
      <Trace>
        <TraceCollapsible defaultOpen>
          <TraceCollapsibleTrigger>
            <TraceLine>
              <TraceIcon>
                <ListChecksIcon />
              </TraceIcon>
              <TraceTitle>Planned 3 steps</TraceTitle>
            </TraceLine>
          </TraceCollapsibleTrigger>
          <TraceCollapsibleContent>
            Read the build log, find the slow step, then fix its cache key.
          </TraceCollapsibleContent>
        </TraceCollapsible>
        <TraceCollapsible open={open} onOpenChange={setOpen}>
          <TraceCollapsibleTrigger>
            <TraceLine>
              <TraceIcon>
                <BrainIcon />
              </TraceIcon>
              <TraceTitle>Thought for 4 seconds</TraceTitle>
            </TraceLine>
          </TraceCollapsibleTrigger>
          <TraceCollapsibleContent>
            Image optimization takes almost four minutes and runs on every
            deploy, because the cache key includes the commit hash.
          </TraceCollapsibleContent>
        </TraceCollapsible>
      </Trace>
      <Button variant="outline" size="sm" onClick={() => setOpen(!open)}>
        {open ? "Close" : "Open"} the second row
      </Button>
    </div>
  )
}

To open a row while it streams and let the reader's click win, pass open ?? running.

const [open, setOpen] = React.useState<boolean>()
 
<TraceCollapsible
  open={open ?? running}
  onOpenChange={setOpen}
>
  <TraceCollapsibleTrigger>
    <TraceLine status={running ? "running" : undefined}>…</TraceLine>
  </TraceCollapsibleTrigger>
  <TraceCollapsibleContent>{text}</TraceCollapsibleContent>
</TraceCollapsible>

Wrap the TraceLine in an anchor. For a new tab, use an arrow-up-right icon and sr-only "(opens in a new tab)".

import { ArrowUpRightIcon, BookOpenIcon } from "lucide-react"

import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace"

export function TraceLink() {
  return (
    <Trace className="max-w-md">
      <a href="https://docs.astro.build" target="_blank" rel="noreferrer">
        <TraceLine>
          <TraceIcon>
            <BookOpenIcon />
          </TraceIcon>
          <TraceTitle>
            Read the Astro install guide
            <span className="sr-only"> (opens in a new tab)</span>
          </TraceTitle>
          <TraceIcon>
            <ArrowUpRightIcon />
          </TraceIcon>
        </TraceLine>
      </a>
    </Trace>
  )
}
<a href="https://docs.astro.build" target="_blank" rel="noreferrer">
  <TraceLine>
    <TraceTitle>
      Install Astro
      <span className="sr-only"> (opens in a new tab)</span>
    </TraceTitle>
    <TraceIcon>
      <ArrowUpRightIcon />
    </TraceIcon>
  </TraceLine>
</a>

Dialog

Wrap the TraceLine in the dialog's trigger. The dialog owns its open state, Escape and focus return.

import { ChevronRightIcon, GlobeIcon } from "lucide-react"

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace"

const results = [
  {
    title: "Build cache · Vercel docs",
    url: "vercel.com/docs/builds/build-cache",
  },
  {
    title: "Caching images · Next.js",
    url: "nextjs.org/docs/app/api-reference/components/image",
  },
  {
    title: "Cache keys explained",
    url: "turbo.build/repo/docs/crafting-your-repository/caching",
  },
]

export function TraceDialog() {
  return (
    <Trace className="max-w-md">
      <Dialog>
        <DialogTrigger>
          <TraceLine>
            <TraceIcon>
              <GlobeIcon />
            </TraceIcon>
            <TraceTitle>Searched the web for cache keys</TraceTitle>
            <TraceIcon>
              <ChevronRightIcon />
            </TraceIcon>
          </TraceLine>
        </DialogTrigger>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>cache keys</DialogTitle>
            <DialogDescription>3 results from the web</DialogDescription>
          </DialogHeader>
          <ul className="flex flex-col gap-3 text-sm">
            {results.map((result) => (
              <li key={result.url} className="flex flex-col">
                <span className="font-medium">{result.title}</span>
                <span className="truncate text-muted-foreground">
                  {result.url}
                </span>
              </li>
            ))}
          </ul>
        </DialogContent>
      </Dialog>
    </Trace>
  )
}
<Dialog>
  <DialogTrigger>
    <TraceLine>
      <TraceTitle>Searched the web for cache keys</TraceTitle>
      <TraceIcon>
        <ChevronRightIcon />
      </TraceIcon>
    </TraceLine>
  </DialogTrigger>
  <DialogContent>…</DialogContent>
</Dialog>

Sheet

The same shape as the dialog. Drawer and Popover work the same way.

import { ChevronRightIcon, FileTextIcon } from "lucide-react"

import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block"
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace"

const readme = `# Acme Chat

A chat app built with Next.js and the AI SDK.

## Getting started

pnpm install
pnpm dev

## Chat route

app/api/chat/route.ts streams responses through the AI Gateway.
Set AI_GATEWAY_API_KEY in .env.local.`

export function TraceSheet() {
  return (
    <Trace className="max-w-md">
      <Sheet>
        <SheetTrigger>
          <TraceLine>
            <TraceIcon>
              <FileTextIcon />
            </TraceIcon>
            <TraceTitle>Read README.md</TraceTitle>
            <TraceIcon>
              <ChevronRightIcon />
            </TraceIcon>
          </TraceLine>
        </SheetTrigger>
        <SheetContent>
          <SheetHeader>
            <SheetTitle className="font-mono">README.md</SheetTitle>
            <SheetDescription>
              {readme.split("\n").length} lines
            </SheetDescription>
          </SheetHeader>
          <CodeBlock className="mx-4 mb-4 min-h-0 w-auto flex-1">
            <CodeBlockContent wrap>{readme}</CodeBlockContent>
          </CodeBlock>
        </SheetContent>
      </Sheet>
    </Trace>
  )
}

Group

Put rows that sit next to each other in a Trace. It also styles any trigger or link that wraps a TraceLine.

Listed app/api
Read the AI SDK docs (opens in a new tab)
import {
  ArrowUpRightIcon,
  FileSearchIcon,
  FolderOpenIcon,
  TerminalIcon,
} from "lucide-react"

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

export function TraceGroup() {
  return (
    <Trace className="max-w-md">
      <TraceLine>
        <TraceIcon>
          <FolderOpenIcon />
        </TraceIcon>
        <TraceTitle>Listed app/api</TraceTitle>
      </TraceLine>
      <TraceCollapsible>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <FileSearchIcon />
            </TraceIcon>
            <TraceTitle>Searched for streamText</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          app/api/chat/route.ts, lib/ai.ts
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <a href="https://ai-sdk.dev/docs" target="_blank" rel="noreferrer">
        <TraceLine>
          <TraceIcon>
            <TerminalIcon />
          </TraceIcon>
          <TraceTitle>
            Read the AI SDK docs
            <span className="sr-only"> (opens in a new tab)</span>
          </TraceTitle>
          <TraceIcon>
            <ArrowUpRightIcon />
          </TraceIcon>
        </TraceLine>
      </a>
    </Trace>
  )
}

Sources

A collapsible row whose detail is a Trace of link rows.

import { ArrowUpRightIcon, GlobeIcon, LinkIcon } from "lucide-react"

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

const sources = [
  {
    title: "Voyager program",
    url: "https://en.wikipedia.org/wiki/Voyager_program",
  },
  { title: "Voyager 1", url: "https://en.wikipedia.org/wiki/Voyager_1" },
  { title: "Voyager 2", url: "https://en.wikipedia.org/wiki/Voyager_2" },
]

export function TraceSources() {
  return (
    <Trace className="max-w-md">
      <TraceCollapsible defaultOpen>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <LinkIcon />
            </TraceIcon>
            <TraceTitle>Used {sources.length} sources</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            {sources.map((source) => (
              <a
                key={source.url}
                href={source.url}
                target="_blank"
                rel="noreferrer"
              >
                <TraceLine>
                  <TraceIcon>
                    <GlobeIcon />
                  </TraceIcon>
                  <TraceTitle>
                    {source.title}
                    <span className="sr-only"> (opens in a new tab)</span>
                  </TraceTitle>
                  <TraceIcon>
                    <ArrowUpRightIcon />
                  </TraceIcon>
                </TraceLine>
              </a>
            ))}
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  )
}

Chain of Thought

A chain of thought is a collapsible row with step rows inside. Set the parent's status and title from its children.

Read the build logEdited vercel.json+3 −1Running pnpm build
import {
  FilePenIcon,
  FileSearchIcon,
  SparklesIcon,
  TerminalIcon,
} from "lucide-react"

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

export function TraceSteps() {
  return (
    <Trace className="max-w-md">
      <TraceCollapsible defaultOpen>
        <TraceCollapsibleTrigger>
          <TraceLine status="running">
            <TraceIcon>
              <SparklesIcon />
            </TraceIcon>
            <TraceTitle>Fixing the build cache</TraceTitle>
            <TraceDescription>2 of 3</TraceDescription>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            <TraceLine>
              <TraceIcon>
                <FileSearchIcon />
              </TraceIcon>
              <TraceTitle>Read the build log</TraceTitle>
            </TraceLine>
            <TraceLine>
              <TraceIcon>
                <FilePenIcon />
              </TraceIcon>
              <TraceTitle>Edited vercel.json</TraceTitle>
              <TraceDescription>+3 −1</TraceDescription>
            </TraceLine>
            <TraceLine status="running">
              <TraceIcon>
                <TerminalIcon />
              </TraceIcon>
              <TraceTitle>Running pnpm build</TraceTitle>
            </TraceLine>
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  )
}

Commit

A commit is a collapsible row with a line for each changed file. Say what changed in the title, Added, Edited or Deleted, and put the line counts in the description. Each file opens its diff in a Sheet.

import {
  FileMinusIcon,
  FilePenIcon,
  FilePlusIcon,
  GitCommitHorizontalIcon,
} from "lucide-react"

import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockLine,
} from "@/components/ui/code-block"
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

const files = [
  {
    action: "Added",
    path: "lib/cache.ts",
    stat: "+18",
    icon: FilePlusIcon,
    diff: [
      { variant: "added", text: 'import { hashFiles } from "./hash"' },
      { variant: "added", text: "" },
      {
        variant: "added",
        text: 'export const cacheKey = () => hashFiles(["pnpm-lock.yaml"])',
      },
    ],
  },
  {
    action: "Edited",
    path: "next.config.ts",
    stat: "+4 −1",
    icon: FilePenIcon,
    diff: [
      { variant: "default", text: "  images: {" },
      { variant: "removed", text: "    cacheKey: process.env.COMMIT_SHA," },
      { variant: "added", text: "    cacheKey: cacheKey()," },
      { variant: "default", text: "  }," },
    ],
  },
  {
    action: "Deleted",
    path: "scripts/warm-cache.sh",
    stat: "−22",
    icon: FileMinusIcon,
    diff: [
      { variant: "removed", text: "#!/usr/bin/env bash" },
      { variant: "removed", text: "pnpm next build --no-cache" },
    ],
  },
] as const

export function TraceCommit() {
  return (
    <Trace className="max-w-md">
      <TraceCollapsible defaultOpen>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <GitCommitHorizontalIcon />
            </TraceIcon>
            <TraceTitle>
              Committed &ldquo;Key the image cache on the lockfile&rdquo;
            </TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            {files.map((file) => (
              <Sheet key={file.path}>
                <SheetTrigger>
                  <TraceLine>
                    <TraceIcon>
                      <file.icon />
                    </TraceIcon>
                    <TraceTitle>
                      {file.action} {file.path}
                    </TraceTitle>
                    <TraceDescription>{file.stat}</TraceDescription>
                  </TraceLine>
                </SheetTrigger>
                <SheetContent>
                  <SheetHeader>
                    <SheetTitle className="font-mono">{file.path}</SheetTitle>
                    <SheetDescription>
                      {file.action}, {file.stat}
                    </SheetDescription>
                  </SheetHeader>
                  <CodeBlock className="mx-4 mb-4 w-auto">
                    <CodeBlockContent>
                      {file.diff.map((line, index) => (
                        <CodeBlockLine key={index} variant={line.variant}>
                          {line.text}
                        </CodeBlockLine>
                      ))}
                    </CodeBlockContent>
                  </CodeBlock>
                </SheetContent>
              </Sheet>
            ))}
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  )
}

Whole Turn

Three levels: the turn, a summary, and the rows. Text between steps sits between two Trace lists.

The image cache never hits because its key includes the commit hash. I'll key it on the lockfile instead.

Edited next.config.ts+4 −1

Builds now skip image optimization when nothing changed.

import {
  BrainIcon,
  FilePenIcon,
  FileSearchIcon,
  ListTreeIcon,
  TerminalIcon,
} from "lucide-react"

import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block"
import { Message, MessageContent } from "@/components/ui/message"
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

export function TraceTurn() {
  return (
    <Message className="max-w-md">
      <MessageContent className="gap-3 text-sm leading-relaxed">
        <Trace>
          <TraceCollapsible>
            <TraceCollapsibleTrigger>
              <TraceLine>
                <TraceIcon>
                  <ListTreeIcon />
                </TraceIcon>
                <TraceTitle>Explored the project</TraceTitle>
                <TraceDescription>3 steps</TraceDescription>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <Trace>
                <TraceLine>
                  <TraceIcon>
                    <BrainIcon />
                  </TraceIcon>
                  <TraceTitle>Thought for 3 seconds</TraceTitle>
                </TraceLine>
                <TraceLine>
                  <TraceIcon>
                    <FileSearchIcon />
                  </TraceIcon>
                  <TraceTitle>Read vercel.json</TraceTitle>
                </TraceLine>
                <TraceLine>
                  <TraceIcon>
                    <FileSearchIcon />
                  </TraceIcon>
                  <TraceTitle>Read next.config.ts</TraceTitle>
                </TraceLine>
              </Trace>
            </TraceCollapsibleContent>
          </TraceCollapsible>
        </Trace>
        <p>
          The image cache never hits because its key includes the commit hash.
          I&apos;ll key it on the lockfile instead.
        </p>
        <Trace>
          <TraceLine>
            <TraceIcon>
              <FilePenIcon />
            </TraceIcon>
            <TraceTitle>Edited next.config.ts</TraceTitle>
            <TraceDescription>+4 −1</TraceDescription>
          </TraceLine>
          <TraceCollapsible>
            <TraceCollapsibleTrigger>
              <TraceLine>
                <TraceIcon>
                  <TerminalIcon />
                </TraceIcon>
                <TraceTitle>Ran pnpm build</TraceTitle>
                <TraceDescription>48s</TraceDescription>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <CodeBlock size="sm">
                <CodeBlockContent wrap>
                  ✓ Compiled successfully in 4.2s
                </CodeBlockContent>
              </CodeBlock>
            </TraceCollapsibleContent>
          </TraceCollapsible>
        </Trace>
        <p>Builds now skip image optimization when nothing changed.</p>
      </MessageContent>
    </Message>
  )
}

Reasoning

Render a reasoning part from the AI SDK. The row is a TraceLine until the first text arrives, opens while it streams, then stays as the reader left it. See Open State.

Not started
"use client"

import * as React from "react"
import { BrainIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

const reasoning = `The user wants to know why the deploy is slow, so I should start from the build log instead of guessing.

Image optimization takes almost four minutes, and it runs on every deploy because the cache key includes the commit hash.`

// Streams the reasoning in, then lets the reader's click decide: the row
// is open while it streams (open ?? running) and stays as the reader left it.
export function TraceReasoning() {
  const [length, setLength] = React.useState(0)
  const [running, setRunning] = React.useState(false)
  const [open, setOpen] = React.useState<boolean>()

  React.useEffect(() => {
    if (!running) {
      return
    }

    const interval = setInterval(() => {
      setLength((current) => {
        const next = Math.min(current + 4, reasoning.length)
        if (next === reasoning.length) {
          setRunning(false)
        }
        return next
      })
    }, 30)

    return () => clearInterval(interval)
  }, [running])

  const text = reasoning.slice(0, length)

  return (
    <div className="flex w-full max-w-md flex-col items-start gap-6">
      <Trace className="min-h-40">
        {text ? (
          <TraceCollapsible open={open ?? running} onOpenChange={setOpen}>
            <TraceCollapsibleTrigger>
              <TraceLine status={running ? "running" : undefined}>
                <TraceIcon>
                  <BrainIcon />
                </TraceIcon>
                <TraceTitle>
                  {running ? "Thinking" : "Thought for 4 seconds"}
                </TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent className="whitespace-pre-wrap">
              {text}
            </TraceCollapsibleContent>
          </TraceCollapsible>
        ) : (
          <TraceLine status={running ? "running" : undefined}>
            <TraceIcon>
              <BrainIcon />
            </TraceIcon>
            <TraceTitle>{running ? "Thinking" : "Not started"}</TraceTitle>
          </TraceLine>
        )}
      </Trace>
      <Button
        variant="outline"
        size="sm"
        disabled={running}
        onClick={() => {
          setLength(0)
          setOpen(undefined)
          setRunning(true)
        }}
      >
        {text ? "Think again" : "Start"}
      </Button>
    </div>
  )
}

Accessibility

Trace adds no roles of its own. Each line gets its semantics from the element that wraps it.

  • A TraceLine on its own is a span. It is read as text and is not a tab stop.
  • Wrap it in a TraceCollapsibleTrigger and it becomes a button with aria-expanded. Wrap it in a link, or in the trigger for a dialog or sheet, and it takes the keyboard and focus behavior of that element.
  • TraceIcon is hidden from assistive tech, so TraceTitle is the accessible name. Write it as a sentence that says what happened, like "Edited trace.tsx", not just "trace.tsx".
  • When a link opens a new tab, add "(opens in a new tab)" to the title with sr-only.
  • Status is shown with color and motion, so put it in the title too. Write "Running pnpm build" while it runs and "Failed to run pnpm build" when it errors.
  • The shimmer on a running title stops when the reader prefers reduced motion. The title still says what is happening.
  • Inside a Message Scroller, each line is announced once when it is added.
  • TraceCollapsibleContent mutes its text. If you put a block with a muted background inside it, give the block text-foreground so it keeps its contrast. CodeBlock sets its own text color.

API Reference

Trace

A list of rows. It also styles any trigger or link that wraps a TraceLine.

PropTypeDefaultDescription
classNamestring-Additional classes to apply to the list.

TraceLine

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.

PropTypeDefaultDescription
status"running" | "error" | "pending"-The state of the work the line reports.
classNamestring-Additional classes to apply to the line.

TraceIcon

A decorative icon slot, hidden from assistive tech. Before the title it names the action; after it, it is the indicator.

PropTypeDefaultDescription
classNamestring-Additional classes to apply to the icon slot.

TraceTitle

One sentence on one line. It truncates and shimmers while the line is running.

PropTypeDefaultDescription
classNamestring-Additional classes to apply to the title.

TraceDescription

Secondary text after the title, such as a count or a duration. It does not truncate.

PropTypeDefaultDescription
classNamestring-Additional classes to apply to the description.

TraceCollapsible

A row that expands. It accepts the props of Collapsible.

PropTypeDefaultDescription
openboolean-The controlled open state. Pass it with onOpenChange.
defaultOpenbooleanfalseWhether the row starts open.
onOpenChange(open: boolean) => void-Called when the open state changes.
classNamestring-Additional classes to apply to the row.

TraceCollapsibleTrigger

The button that opens and closes the row. Put a TraceLine inside it. It adds a chevron after the line.

PropTypeDefaultDescription
classNamestring-Additional classes to apply to the trigger.

TraceCollapsibleContent

The detail of a row that expands.

PropTypeDefaultDescription
keepMountedbooleanfalseKeep the detail mounted while closed, so nested rows keep their open state.
classNamestring-Additional classes to apply to the content.