---
title: Steps
description: A collapsible timeline of a run — reasoning, tool calls, and answered questions as chronological steps.
source: steps
---

```tsx title="primitives/steps/demos/basic.tsx"
"use client";

import { Steps } from "@intentface/chat/steps";
import type { ComponentProps } from "react";

// Steps is recursive: an item's panel can hold rows and further items. A nested
// panel picks up data-nested, which is how the rail indent is drawn.
export const Basic = () => (
  <div className="w-full max-w-xl">
    <Steps.Root className="w-full">
      <Steps.Item defaultOpen>
        <Steps.Trigger className="group/trigger flex w-full cursor-pointer items-center gap-2 py-1 text-sm text-[#686868] transition-colors hover:text-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:text-[#fcfcfc]">
          <span>Worked for 3 seconds</span>
          <ChevronIcon className="size-4 shrink-0 -rotate-90 transition-transform group-data-open/trigger:rotate-0" />
        </Steps.Trigger>
        <Steps.Panel className="mt-2 flex flex-col in-data-nested:ml-2 in-data-nested:border-l in-data-nested:border-[#f0f0f0] in-data-nested:pl-4 dark:in-data-nested:border-[#262626]">
          <div className="flex items-center gap-2 py-0.5">
            <Steps.Icon className={ICON_CLASS}>
              <CheckIcon />
            </Steps.Icon>
            <Steps.Label className={LABEL_CLASS}>Read the request</Steps.Label>
          </div>

          <Steps.Item defaultOpen>
            <Steps.Trigger className="group/trigger flex w-full cursor-pointer items-center gap-2 py-0.5">
              <Steps.Icon className={ICON_CLASS}>
                <CheckIcon />
              </Steps.Icon>
              <Steps.Label className={LABEL_CLASS}>Searched the web</Steps.Label>
            </Steps.Trigger>
            <Steps.Panel className="flex flex-col in-data-nested:ml-2 in-data-nested:border-l in-data-nested:border-[#f0f0f0] in-data-nested:pl-4 dark:in-data-nested:border-[#262626]">
              <span className="py-0.5 text-sm text-[#686868] dark:text-[#9b9b9b]">
                Found three relevant sources and skimmed each.
              </span>
            </Steps.Panel>
          </Steps.Item>

          <div className="flex items-center gap-2 py-0.5">
            <Steps.Icon status="active" className={ICON_CLASS}>
              <CircleIcon className="animate-pulse" />
            </Steps.Icon>
            <Steps.Label status="active" className={LABEL_CLASS}>
              Writing the answer
            </Steps.Label>
          </div>
        </Steps.Panel>
      </Steps.Item>
    </Steps.Root>
  </div>
);

// Status is inherited from the enclosing item and surfaced as data-status, so
// one class string covers every state.
const ICON_CLASS =
  "flex size-4 shrink-0 items-center justify-center data-[status=complete]:text-[#686868] data-[status=active]:text-[#1a1a1a] data-[status=pending]:text-[#949494] dark:data-[status=complete]:text-[#9b9b9b] dark:data-[status=active]:text-[#fcfcfc] dark:data-[status=pending]:text-[#6f6f6f]";

const LABEL_CLASS =
  "text-left text-sm data-[status=complete]:text-[#686868] data-[status=active]:font-medium data-[status=active]:text-[#1a1a1a] data-[status=pending]:text-[#949494] dark:data-[status=complete]:text-[#9b9b9b] dark:data-[status=active]:text-[#fcfcfc] dark:data-[status=pending]:text-[#6f6f6f]";

const ChevronIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="m4 6 4 4 4-4" />
  </svg>
);

const CheckIcon = (props: ComponentProps<"svg">) => (
  <svg
    width="14"
    height="14"
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.75"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="m2.5 8.5 4 4 7-9" />
  </svg>
);

const CircleIcon = (props: ComponentProps<"svg">) => (
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" {...props}>
    <circle cx="8" cy="8" r="4" />
  </svg>
);
```

## Usage guidelines

- **Recursive disclosure tree** — every node is a `Steps.Item` with a `Trigger` and a `Panel`, and panels can hold further items, so timelines nest arbitrarily.
- **Status-driven** — each item's `status` (`complete` / `active` / `pending`) flows to its `Icon` and `Label` via context; active items open by default.
- **Nesting** — a nested item surfaces `data-nested` for the indent rail; a static row is just an `Icon` + `Label` in a `<div>`.
- **You compose the rows** — the primitive ships the disclosure + status plumbing; row content (icons, tool-call summaries) is yours to render.
- **Get started** — see [Quick start](/docs/quick-start) to add the package.

## Anatomy

A timeline is a top-level item whose panel holds rows; a row is an `Icon` +
`Label`, and a row that expands is itself a nested `Steps.Item`:

```tsx
<Steps.Root>
  <Steps.Item defaultOpen>
    <Steps.Trigger>
      <span>Worked for 3 seconds</span>
    </Steps.Trigger>
    <Steps.Panel>
      {/* a static, complete row */}
      <div>
        <Steps.Icon>{checkIcon}</Steps.Icon>
        <Steps.Label>Read the request</Steps.Label>
      </div>

      {/* a nested, expandable row */}
      <Steps.Item defaultOpen>
        <Steps.Trigger>
          <Steps.Icon>{checkIcon}</Steps.Icon>
          <Steps.Label>Searched the web</Steps.Label>
        </Steps.Trigger>
        <Steps.Panel>Found three relevant sources and skimmed each.</Steps.Panel>
      </Steps.Item>

      {/* an in-progress row — status overrides icon + label styling */}
      <div>
        <Steps.Icon status="active">{spinnerIcon}</Steps.Icon>
        <Steps.Label status="active">Writing the answer</Steps.Label>
      </div>
    </Steps.Panel>
  </Steps.Item>
</Steps.Root>
```

## Status

`status` is an opaque `string` — you own the set (commonly `"complete"`,
`"active"`, `"pending"`, but add `"error"`/`"skipped"`/anything). `Steps.Item`
takes a `status` and publishes it through context; `Steps.Icon` and
`Steps.Label` inherit it, or override with their own `status`. Resolution is
*own prop → inherited → `"complete"`*. Every status-aware part reflects it as
`data-status` for styling.

## Keyboard

Each item is a standard disclosure: `Steps.Trigger` is a real button, so
`Tab` moves between triggers and `Enter`/`Space` toggle the nearest item.
Nested items nest their own triggers — there is no roving focus or composite
widget; the tree is plain sequential tab order.

## Accessibility

Disclosure semantics are inherited from the underlying Collapsible:
`aria-expanded`/`aria-controls` on the trigger, an id-linked, `hidden`-managed
panel. The item whose `status` is `"active"` additionally carries
`aria-current="step"` — the same "active" convention `defaultOpen` already
keys off.

Status itself is invisible to assistive tech by default: `Steps.Icon` is
`aria-hidden` and color never announces. Mount `Steps.Status` inside rows
whose status matters — it renders a visually-hidden span speaking the resolved
status string, and takes `children` for localized copy:

```tsx
<Steps.Trigger>
  <Steps.Icon />
  <Steps.Label>Searching the web</Steps.Label>
  <Steps.Status />
</Steps.Trigger>
```

## API reference

Every part accepts `className`, `style`, and `render` (see
[Styling](/docs/handbook/styling)) and emits a bespoke part attribute (`data-<part>`) unless noted.

### Steps

The timeline root. Renders `data-steps`. No part-specific props.

### Steps.Item

One node of the tree (a disclosure). Renders `data-steps-item`, plus
`aria-current="step"` while `status` is `"active"`.

export const itemProps = [
  { name: "status", type: "string", default: '"complete"', description: "Node status (commonly complete / active / pending); seeds context for Icon/Label and drives data-status." },
  { name: "defaultOpen", type: "boolean", default: "status === active", description: "Uncontrolled initial open state — open by default while active." },
  { name: "open", type: "boolean", description: "Controlled open state." },
  { name: "onOpenChange", type: "(open: boolean) => void", description: "Fires on toggle." },
];

<PropsTable rows={itemProps} />

export const itemAttrs = [
  { attribute: "data-steps-item", description: "The item element." },
  { attribute: "data-status", values: "string", description: "The item's status (commonly complete / active / pending)." },
  { attribute: "data-nested", values: '"true"', description: "Present when the item is inside another item (indent rail)." },
  { attribute: "data-open", description: "Present while open." },
  { attribute: "data-closed", description: "Present while closed." },
];

<AttributesTable rows={itemAttrs} />

### Steps.Trigger

Toggles the nearest item. Renders a `<button data-steps-trigger>`
(`aria-expanded`, `aria-controls`). Carries `data-open`/`data-closed` for the
chevron. The styled layer groups it as `group/steps-trigger` so children read
`group-data-open/steps-trigger:…`.

### Steps.Panel

The nearest item's disclosure area — lays out the timeline column. Renders
`data-steps-panel`.

export const panelProps = [
  { name: "keepMounted", type: "boolean", default: "false", description: "Keep the panel in the DOM (hidden) when closed." },
];

<PropsTable rows={panelProps} />

export const panelAttrs = [
  { attribute: "data-steps-panel", description: "The panel." },
  { attribute: "data-open", description: "Present while open." },
  { attribute: "data-closed", description: "Present while closed." },
  { attribute: "data-starting-style", description: "Present on the first open frame (enter transition)." },
  { attribute: "data-ending-style", description: "Present while the exit animation runs." },
  { attribute: "--panel-height", values: "measured px", description: "The panel's natural height, published only while the open or close transition runs so a height transition has a number to animate from. Deliberately released once the panel settles open, which makes `height: var(--panel-height)` fall back to `auto` so the open panel tracks content that grows inside it." },
];

<AttributesTable rows={panelAttrs} />

### Steps.Icon

Status indicator. Renders `<span data-steps-icon aria-hidden>`.

export const iconProps = [
  { name: "status", type: "string", description: "Overrides the inherited status for this icon." },
];

<PropsTable rows={iconProps} />

export const iconAttrs = [
  { attribute: "data-steps-icon", description: "The icon element." },
  { attribute: "data-status", values: "string", description: "Resolved status, for styling." },
];

<AttributesTable rows={iconAttrs} />

### Steps.Label

Row text. Renders `<span data-steps-label>`.

export const labelProps = [
  { name: "status", type: "string", description: "Overrides the inherited status for this label." },
];

<PropsTable rows={labelProps} />

export const labelAttrs = [
  { attribute: "data-steps-label", description: "The label element." },
  { attribute: "data-status", values: "string", description: "Resolved status, for styling." },
];

<AttributesTable rows={labelAttrs} />

### Steps.Status

Visually-hidden status announcement. Renders `<span data-steps-status>` with
screen-reader-only styling (overridable via `style`/`className`), containing
the resolved status string unless `children` provide localized copy.

export const statusProps = [
  { name: "status", type: "string", description: "Overrides the inherited status for this announcement." },
  { name: "children", type: "ReactNode", default: "the resolved status string", description: "Localized copy to announce instead of the raw status value." },
];

<PropsTable rows={statusProps} />

export const statusAttrs = [
  { attribute: "data-steps-status", description: "The status element." },
];

<AttributesTable rows={statusAttrs} />
