# Thinking State

> Show that an agent is working, and keep its steps, reasoning, searches, and sources one press away.

Source: https://ai.honestui.com/docs/components/thinking-state

```tsx
"use client"

import * as React from "react"

import { ThinkingState, ThinkingStep } from "@/components/ai/thinking-state"
import { Button } from "@/components/ui/button"

// Sample trace.
const sampleSteps = [
  { id: "read", label: "Reading the export module", detail: "3 files" },
  { id: "trace", label: "Tracing where totals are rounded" },
  { id: "compare", label: "Comparing against the invoice schema", detail: "12 fields" },
  { id: "plan", label: "Writing the change plan" },
]

const STEP_MILLISECONDS = 900

/**
 * No agent runs behind this preview. A timer completes one step at a time so
 * you can see the running state settle; your application sets `status` and
 * the steps from its own events.
 */
export function ThinkingStateDemo() {
  const [completed, setCompleted] = React.useState(0)
  const isRunning = completed < sampleSteps.length

  React.useEffect(() => {
    if (!isRunning) return
    const timer = window.setTimeout(() => setCompleted((count) => count + 1), STEP_MILLISECONDS)
    return () => window.clearTimeout(timer)
  }, [completed, isRunning])

  return (
    <div className="flex w-full max-w-sm min-w-0 flex-col items-start gap-[var(--hui-space-5)]">
      <ThinkingState
        status={isRunning ? "running" : "done"}
        label={isRunning ? "Thinking" : `Finished ${sampleSteps.length} steps`}
      >
        {sampleSteps.slice(0, completed + 1).map((step, index) => (
          <ThinkingStep
            key={step.id}
            status={index < completed ? "done" : "running"}
            detail={step.detail}
          >
            {step.label}
          </ThinkingStep>
        ))}
      </ThinkingState>
      <Button variant="secondary" size="sm" disabled={isRunning} onClick={() => setCompleted(0)}>
        Run the sample again
      </Button>
    </div>
  )
}

```

## Overview [#overview]

Thinking State is a one-line header that says what the agent is doing, with a trace behind it. While the agent works, the header shimmers and the trace is open so new rows are visible as they arrive. When the work settles, the trace closes and the header becomes a quiet summary that still opens on request.

Take a question that needs a web search. The header reads "Searching the web" while the query and the sources appear under it. When the search ends, you change the label to "Read 2 sources" and set the status to done. The trace closes, and the person can reopen it to see what was read.

Thinking State does not run anything and does not keep time. You pass the status, the label, and the rows. Use [Tool Chips](/docs/components/tool-chips) instead when the run is mostly tool calls with output and file changes.

## Anatomy [#anatomy]

<Anatomy title="ThinkingState">
  <AnatomyItem name="ThinkingState" description="The header and the disclosure that holds the trace">
    <AnatomyItem name="ThinkingStep" description="One step of work, with a running, done, or failed mark" />

    <AnatomyItem name="ThinkingText" description="A paragraph of reasoning" />

    <AnatomyItem name="ThinkingQuery" description="A search the agent ran" />

    <AnatomyItem name="ThinkingSource" description="A page the agent read, as a link" />
  </AnatomyItem>
</Anatomy>

The four row types can be mixed in any order. Installing `thinking-state` brings [Collapsible](https://www.honestui.com/docs/components/collapsible) and [Text Shimmer](https://www.honestui.com/docs/animated/text-shimmer) along as registry dependencies.

## Behavior [#behavior]

### Status [#status]

`status` is `running`, `done`, or `error`. Running shimmers the label. Done sets it in the secondary text color. Error replaces the header icon with an alert icon in the danger color and sets the label in the primary text color, so it stays readable.

### Open and closed [#open-and-closed]

Without `open` or `defaultOpen`, the trace is open while the status is running and closed otherwise. Once the person opens or closes it, their choice stays, whatever the status does next. Pass `defaultOpen` to choose the starting state yourself, or `open` and `onOpenChange` to control it.

With no children, the header is plain text with no disclosure.

### New rows [#new-rows]

A row that is added while the trace is open fades in. Rows keep their position, so nothing above the new row moves.

## Accessibility [#accessibility]

The header is a button that reports whether the trace is expanded. The trace is a list, and each row is a list item.

When `label` changes, the new label is announced through a polite status region. The first label is not announced, because it is already in the reading order.

Each step ends with words for its status, read only by screen readers: "In progress", "Done", or "Failed". Pass `statusLabel` to change them. The query row is read with "Searched for" before it. Icons are decorative.

With reduced motion, the shimmer stops, the spinner does not rotate, and rows appear without fading.

`ThinkingSource` renders a link and passes other anchor props through. If you open sources in a new tab, say so in the link's name.

| Key                                  | Action                                                        |
| ------------------------------------ | ------------------------------------------------------------- |
| <kbd>Tab</kbd>                       | Move to the header, then to each source link in an open trace |
| <kbd>Enter</kbd> or <kbd>Space</kbd> | Open or close the trace                                       |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/thinking-state&#x22;]" />
  

  
    
      
        Add the components it is built from.
      

      <CliBlock commands="[&#x22;collapsible&#x22;, &#x22;text-shimmer&#x22;]" />

      
        Copy and paste this file into your project.
      

      ### components/ai/thinking-state.tsx

```tsx
"use client"

import * as React from "react"
import {
  Check as CheckIcon,
  ChevronDown as ChevronDownIcon,
  CircleAlert as CircleAlertIcon,
  Globe as GlobeIcon,
  LoaderCircle as LoaderCircleIcon,
  Search as SearchIcon,
  Sparkles as SparklesIcon,
} from "honestui/icons"

import { cn } from "@/lib/utils"
import { TextShimmer } from "@/components/animated/text-shimmer"
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"

export type ThinkingStatus = "running" | "done" | "error"

const labelText =
  "[font-size:var(--hui-font-size-small)] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

const rowText =
  "[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

/** New rows fade in as the trace grows. Skipped when motion is reduced. */
const rowEnter =
  "motion-safe:transition-[opacity,translate] motion-safe:duration-300 motion-safe:starting:translate-y-1 motion-safe:starting:opacity-0"

const statusTone: Record<ThinkingStatus, string> = {
  running: "text-[var(--hui-color-foreground-base-secondary)]",
  done: "text-[var(--hui-color-foreground-base-tertiary)]",
  error: "text-[var(--hui-color-foreground-danger-primary)]",
}

export type ThinkingStateProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** What the agent is doing now. Drives the header treatment and the default open state. */
  status: ThinkingStatus
  /** The header text, such as "Thinking" or "Thought for 4 seconds". */
  label: string
  /** Replaces the header icon. */
  icon?: React.ReactNode
  /** Controls whether the trace is shown. */
  open?: boolean
  /** The initial open state. Without it, the trace is open while `status` is "running". */
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  /** The trace: `ThinkingStep`, `ThinkingText`, `ThinkingQuery`, and `ThinkingSource`. */
  children?: React.ReactNode
}

/**
 * A header that says what the agent is doing, with the trace behind it. The
 * trace follows `status` (open while running, closed once settled) until the
 * person opens or closes it, and their choice then stays.
 */
function ThinkingState({
  status,
  label,
  icon,
  open: openProp,
  defaultOpen,
  onOpenChange,
  children,
  className,
  ...props
}: ThinkingStateProps) {
  const [chosenOpen, setChosenOpen] = React.useState(defaultOpen)
  const open = openProp ?? chosenOpen ?? status === "running"
  const hasTrace = React.Children.count(children) > 0

  // Announce the label only when it changes, so the first render is not read twice.
  const [announcement, setAnnouncement] = React.useState("")
  const previousLabel = React.useRef(label)
  React.useEffect(() => {
    if (previousLabel.current === label) return
    previousLabel.current = label
    setAnnouncement(label)
  }, [label])

  const header = (
    <>
      <span aria-hidden="true" className={cn("flex shrink-0 [&_svg]:size-4", statusTone[status])}>
        {status === "error" ? <CircleAlertIcon strokeWidth={1.5} strokeLinecap="round" /> : (icon ?? <SparklesIcon />)}
      </span>
      {status === "running" ? (
        <TextShimmer duration={1.6} className={labelText}>
          {label}
        </TextShimmer>
      ) : (
        <span
          className={cn(
            labelText,
            // The alert icon carries the danger color. The label keeps a text color that stays readable.
            status === "error"
              ? "text-[var(--hui-color-foreground-base-primary)]"
              : "text-[var(--hui-color-foreground-base-secondary)]"
          )}
        >
          {label}
        </span>
      )}
    </>
  )

  return (
    <div
      data-slot="thinking-state"
      data-status={status}
      className={cn("flex w-full min-w-0 flex-col", className)}
      {...props}
    >
      <span role="status" className="sr-only">
        {announcement}
      </span>
      {hasTrace ? (
        <Collapsible
          open={open}
          onOpenChange={(nextOpen) => {
            setChosenOpen(nextOpen)
            onOpenChange?.(nextOpen)
          }}
        >
          <CollapsibleTrigger
            data-slot="thinking-state-trigger"
            className="group/thinking -mx-[var(--hui-space-2)] flex w-fit max-w-full items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-2)] border-0 bg-transparent px-[var(--hui-space-2)] py-[var(--hui-space-2)] text-left outline-none motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)]"
          >
            {header}
            <ChevronDownIcon
              aria-hidden="true"
              className="size-3.5 shrink-0 text-[var(--hui-color-foreground-base-tertiary)] motion-safe:transition-transform motion-safe:duration-200 group-data-panel-open/thinking:rotate-180"
            />
          </CollapsibleTrigger>
          <CollapsiblePanel>
            <ul
              role="list"
              data-slot="thinking-state-trace"
              className="m-0 ms-[var(--hui-space-3)] mt-[var(--hui-space-2)] flex list-none flex-col gap-[var(--hui-space-2)] border-s border-[var(--hui-color-border-base-primary)] py-[var(--hui-space-2)] ps-[var(--hui-space-4)]"
            >
              {children}
            </ul>
          </CollapsiblePanel>
        </Collapsible>
      ) : (
        <div className="flex items-center gap-[var(--hui-space-3)] py-[var(--hui-space-2)]">{header}</div>
      )}
    </div>
  )
}

export type ThinkingStepProps = React.ComponentProps<"li"> & {
  /** Defaults to "done". */
  status?: ThinkingStatus
  /** Secondary text after the step, such as a count or a file name. */
  detail?: React.ReactNode
  /** Sets `detail` in the monospace face, for file names and commands. */
  detailIsCode?: boolean
  /** Replaces the words read for the step's status. */
  statusLabel?: string
}

const defaultStepStatusLabel: Record<ThinkingStatus, string> = {
  running: "In progress",
  done: "Done",
  error: "Failed",
}

/** One step of work, with an icon and words for whether it is running, done, or failed. */
function ThinkingStep({
  status = "done",
  detail,
  detailIsCode = false,
  statusLabel,
  className,
  children,
  ...props
}: ThinkingStepProps) {
  const Icon =
    status === "running" ? LoaderCircleIcon : status === "error" ? CircleAlertIcon : CheckIcon

  return (
    <li
      data-slot="thinking-step"
      data-status={status}
      className={cn(
        "flex min-w-0 items-start gap-[var(--hui-space-3)]",
        rowText,
        rowEnter,
        className
      )}
      {...props}
    >
      <Icon
        strokeWidth={1.5}
        strokeLinecap="round"
        aria-hidden="true"
        className={cn(
          "mt-px size-3.5 shrink-0",
          statusTone[status],
          status === "running" && "motion-safe:animate-spin"
        )}
      />
      <span className="min-w-0 break-words text-[var(--hui-color-foreground-base-primary)] [font-weight:var(--hui-font-weight-medium)]">
        {children}
        <span className="sr-only"> ({statusLabel ?? defaultStepStatusLabel[status]})</span>
      </span>
      {detail !== undefined && (
        <span
          className={cn(
            "min-w-0 truncate text-[var(--hui-color-foreground-base-secondary)]",
            detailIsCode && "font-mono [font-size:var(--hui-font-size-mono-mini)]"
          )}
        >
          {detail}
        </span>
      )}
    </li>
  )
}

/** A paragraph of reasoning. */
function ThinkingText({ className, ...props }: React.ComponentProps<"li">) {
  return (
    <li
      data-slot="thinking-text"
      className={cn(
        "min-w-0 break-words text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-regular)]",
        rowEnter,
        className
      )}
      {...props}
    />
  )
}

export type ThinkingQueryProps = React.ComponentProps<"li"> & {
  /** Read before the query, so the row is not just a bare phrase. */
  prefix?: string
}

/** The search the agent ran. */
function ThinkingQuery({ prefix = "Searched for", className, children, ...props }: ThinkingQueryProps) {
  return (
    <li
      data-slot="thinking-query"
      className={cn(
        "flex min-w-0 items-start gap-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-secondary)]",
        rowText,
        rowEnter,
        className
      )}
      {...props}
    >
      <SearchIcon
        aria-hidden="true"
        className="mt-px size-3.5 shrink-0 text-[var(--hui-color-foreground-base-tertiary)]"
      />
      <span className="min-w-0 break-words">
        <span className="sr-only">{prefix} </span>
        {children}
      </span>
    </li>
  )
}

export type ThinkingSourceProps = Omit<React.ComponentProps<"a">, "title"> & {
  href: string
  /** The page or site name. */
  title: React.ReactNode
  /** The host, shown after the title. */
  domain?: string
}

/** A page the agent read, as a link. Other anchor props pass through. */
function ThinkingSource({ title, domain, className, ...props }: ThinkingSourceProps) {
  return (
    <li data-slot="thinking-source" className={cn("min-w-0", rowEnter)}>
      <a
        className={cn(
          "group/source -mx-[var(--hui-space-2)] flex min-w-0 items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-2)] px-[var(--hui-space-2)] py-[var(--hui-space-1)] no-underline outline-none motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)]",
          rowText,
          className
        )}
        {...props}
      >
        <GlobeIcon
          aria-hidden="true"
          className="size-3.5 shrink-0 text-[var(--hui-color-foreground-base-tertiary)]"
        />
        <span className="min-w-0 truncate text-[var(--hui-color-foreground-base-primary)] underline decoration-[var(--hui-color-border-base-secondary)] underline-offset-2 [font-weight:var(--hui-font-weight-medium)] group-hover/source:decoration-current">
          {title}
        </span>
        {domain && (
          <span className="shrink-0 text-[var(--hui-color-foreground-base-secondary)]">{domain}</span>
        )}
      </a>
    </li>
  )
}

export { ThinkingState, ThinkingStep, ThinkingText, ThinkingQuery, ThinkingSource }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

Set the status and the label from your agent's state, and render one row for each event you have received:

```tsx
import { ThinkingState, ThinkingStep } from "@/components/ai/thinking-state"

<ThinkingState
  status={run.isFinished ? "done" : "running"}
  label={run.isFinished ? `Finished ${run.steps.length} steps` : "Thinking"}
>
  {run.steps.map((step) => (
    <ThinkingStep key={step.id} status={step.status} detail={step.detail}>
      {step.title}
    </ThinkingStep>
  ))}
</ThinkingState>
```

Write the settled label as a result. "Read 2 sources" tells the person more than "Done".

## Examples [#examples]

### Search and sources [#search-and-sources]

A settled search, open from the start. The sources are real links.

```tsx
import {
  ThinkingQuery,
  ThinkingSource,
  ThinkingState,
  ThinkingText,
} from "@/components/ai/thinking-state"

export function ThinkingStateSearchExample() {
  return (
    <div className="w-full max-w-sm min-w-0">
      <ThinkingState status="done" label="Read 2 sources" defaultOpen>
        <ThinkingQuery>focus indicator contrast requirement</ThinkingQuery>
        <ThinkingSource
          href="https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html"
          title="Understanding Focus Appearance"
          domain="w3.org"
        />
        <ThinkingSource
          href="https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html"
          title="Understanding Non-text Contrast"
          domain="w3.org"
        />
        <ThinkingText>
          Both pages agree on a 3:1 ratio against adjacent colors, so the ring only needs a darker
          border token.
        </ThinkingText>
      </ThinkingState>
    </div>
  )
}

```

### A failed run [#a-failed-run]

Set the status to `error` on the header and on the step that failed, and say what happened in the label.

```tsx
import { ThinkingState, ThinkingStep } from "@/components/ai/thinking-state"

export function ThinkingStateErrorExample() {
  return (
    <div className="w-full max-w-sm min-w-0">
      <ThinkingState status="error" label="Stopped after 2 steps" defaultOpen>
        <ThinkingStep detail="schema.sql" detailIsCode>
          Read
        </ThinkingStep>
        <ThinkingStep status="error" detail="npm run migrate" detailIsCode>
          Run
        </ThinkingStep>
      </ThinkingState>
    </div>
  )
}

```

## API reference [#api-reference]

### ThinkingState [#thinkingstate]

Accepts native div props beyond those listed.

| Prop           | Type                             | Default          | Description                                                             |
| -------------- | -------------------------------- | ---------------- | ----------------------------------------------------------------------- |
| `status`       | `"running" \| "done" \| "error"` | Required         | What the agent is doing now.                                            |
| `label`        | string                           | Required         | The header text. Announced when it changes.                             |
| `icon`         | ReactNode                        | Sparkles icon    | Replaces the header icon. The error status always shows the alert icon. |
| `open`         | boolean                          | None             | Controls whether the trace is shown.                                    |
| `defaultOpen`  | boolean                          | Follows `status` | The initial open state.                                                 |
| `onOpenChange` | `(open: boolean) => void`        | None             | Runs when the person opens or closes the trace.                         |

### ThinkingStep [#thinkingstep]

Accepts native li props beyond those listed.

| Prop           | Type                             | Default  | Description                          |
| -------------- | -------------------------------- | -------- | ------------------------------------ |
| `status`       | `"running" \| "done" \| "error"` | `"done"` | The mark shown before the step.      |
| `detail`       | ReactNode                        | None     | Secondary text after the step.       |
| `detailIsCode` | boolean                          | `false`  | Sets `detail` in the monospace face. |
| `statusLabel`  | string                           | English  | The words read for the status.       |

### ThinkingText [#thinkingtext]

Accepts native li props. Its children are the paragraph.

### ThinkingQuery [#thinkingquery]

Accepts native li props beyond those listed. Its children are the query.

| Prop     | Type   | Default          | Description                              |
| -------- | ------ | ---------------- | ---------------------------------------- |
| `prefix` | string | `"Searched for"` | Read before the query by screen readers. |

### ThinkingSource [#thinkingsource]

Accepts native anchor props beyond those listed.

| Prop     | Type      | Default  | Description                      |
| -------- | --------- | -------- | -------------------------------- |
| `href`   | string    | Required | The page that was read.          |
| `title`  | ReactNode | Required | The page or site name.           |
| `domain` | string    | None     | The host, shown after the title. |
