---
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.
//
// Two disclosure idioms, both keyed off group-data-open/steps-trigger: the
// timeline header carries a chevron on the right, while a row's status icon
// morphs into a chevron, so a row gains an affordance without gaining a second
// glyph. The morph triggers on focus-visible as well as hover — otherwise a
// keyboard user tabbing onto a closed row gets no hint that it expands.
//
// The panels animate their height from --panel-height (see PANEL_CLASS). Collapse
// and expand the timeline to see it; expand "Searched the web" while the timeline
// is already open to see the outer panel grow to fit, rather than clipping.
export const Basic = () => (
  <div className="w-full max-w-xl">
    <Steps.Root className="w-full">
      <Steps.Item defaultOpen>
        <Steps.Trigger className={`${TRIGGER_CLASS} py-1`}>
          <span>Worked for 3 seconds</span>
          <ChevronIcon className="size-4 shrink-0 -rotate-90 transition-transform group-data-open/steps-trigger:rotate-0" />
        </Steps.Trigger>

        <Steps.Panel className={`${PANEL_CLASS}`}>
          <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>

          {/* Closed by default, so opening it grows the settled outer panel. */}
          <Steps.Item>
            <Steps.Trigger className={`${TRIGGER_CLASS} py-0.5`}>
              <Steps.Icon className={`relative ${ICON_CLASS}`}>
                <span className="transition-opacity group-hover/steps-trigger:opacity-0 group-focus-visible/steps-trigger:opacity-0 group-data-open/steps-trigger:opacity-0">
                  <CheckIcon />
                </span>
                <ChevronIcon className="absolute size-4 opacity-0 transition-all group-hover/steps-trigger:opacity-100 group-focus-visible/steps-trigger:opacity-100 group-data-open/steps-trigger:rotate-180 group-data-open/steps-trigger:opacity-100" />
              </Steps.Icon>
              <Steps.Label className={LABEL_CLASS}>Searched the web</Steps.Label>
            </Steps.Trigger>
            <Steps.Panel className={PANEL_CLASS}>
              <span className="py-0.5 text-sm text-[#686868] dark:text-[#9b9b9b]">
                Found three relevant sources and skimmed each. This detail is what the outer panel
                has to make room for.
              </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>
);

// The group name children read open state through — `steps-trigger` is the name
// the styled layer uses, so these classes port between the two unchanged.
const TRIGGER_CLASS =
  "group/steps-trigger flex w-full cursor-pointer items-center gap-2 text-sm text-[#686868] transition-colors hover:text-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:text-[#fcfcfc]";

// Height animates from --panel-height, which the panel publishes while a
// transition runs and releases once open — so this both animates the open/close
// and lets an open panel grow with its content. The data-starting/ending-style
// variants clamp it to 0 on the transitional frames and outrank the base height,
// since a data-attribute variant is more specific.
//
// [&>*]:shrink-0 guards the measurement: a flex column clamped to height 0 puts
// every child under shrink pressure, and a child collapsing to nothing would make
// the panel measure itself as 0px.
const PANEL_CLASS =
  "flex flex-col overflow-hidden h-(--panel-height) transition-[height] duration-200 ease-out data-starting-style:h-0 data-ending-style:h-0 [&>*]:shrink-0 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]";

// 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](/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.

## Height transitions

The panel publishes its measured height as `--panel-height` while an open or
close transition runs, and **releases it once the panel settles open**. So
`height: var(--panel-height)` animates from a real number, and then — with the
variable no longer written — becomes invalid at computed-value time and falls
back to `auto`. That is what lets an open panel track content appearing inside
it, rather than staying pinned to the height it had when it opened.

The demo at the top of this page uses it. Collapse and expand the timeline to
see the transition, then expand **Searched the web** while the timeline is
already open — the outer panel grows to fit the detail instead of clipping it.

Two details in that demo are load-bearing. `data-starting-style` and
`data-ending-style` clamp the height to `0` on the transitional frames, and they
outrank the base `height` because a data-attribute variant is more specific.
And `[&>*]:shrink-0` guards the measurement: a flex column clamped to `height: 0`
puts every child under shrink pressure, and a child that collapses to nothing
makes the panel measure itself as `0px`.

## 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](/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} />
