---
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 { IconCheck, IconChevronDown, IconCircle } from "@tabler/icons-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>
          <IconChevronDown 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}>
              <IconCheck className="size-3.5" />
            </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">
                  <IconCheck className="size-3.5" />
                </span>
                <IconChevronDown 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}>
              <IconCircle className="size-3.5 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]";
```

## 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 an `Icon` and a `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.
- **No composite keyboard model** — each trigger is a real button, so the tree is plain sequential tab order with no roving focus to learn.
- **Panels animate from a published height** — see [Why the panel releases its height](#why-the-panel-releases-its-height), which is also why an open panel keeps growing.
- **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>
```

## Examples

### Driving rows from status

`status` is an opaque string. The package resolves it — own prop, then inherited
from the enclosing item, then `"complete"` — and reflects it as `data-status`.
It never decides what the set is, so the `"error"` below is a string this demo
invented and then styled.

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

import { Steps } from "@intentface/chat/steps";
import { IconCheck, IconCircle, IconLoader2, IconX } from "@tabler/icons-react";
import { type ComponentProps, useEffect, useRef, useState } from "react";

const ROWS = ["Read the request", "Searched the web", "Checked the cache", "Wrote the answer"];

/*
 * `status` is an opaque string. The package resolves it — own prop, then
 * inherited from the enclosing item, then "complete" — and reflects it as
 * `data-status`. It never decides what the set is.
 *
 * So "error" and "skipped" below are not features; they are strings this demo
 * invented and then styled. Run it and watch each row move through pending,
 * active and complete, with one failing on the way.
 */
export const Status = () => {
  const [reached, setReached] = useState(ROWS.length);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);

  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const run = () => {
    timers.current.forEach(clearTimeout);
    timers.current = [];
    setReached(0);
    ROWS.forEach((_, index) => {
      timers.current.push(setTimeout(() => setReached(index + 1), (index + 1) * 800));
    });
  };

  const statusFor = (index: number) => {
    if (index === 2 && reached > 2) return "error";
    if (index < reached) return "complete";
    if (index === reached) return "active";
    return "pending";
  };

  const running = reached < ROWS.length;

  return (
    <div className="flex w-full max-w-lg flex-col gap-3">
      <Steps.Root className="rounded-xl border border-[#f0f0f0] bg-white p-3 dark:border-[#262626] dark:bg-[#181818]">
        <Steps.Item defaultOpen>
          <Steps.Trigger className="flex w-full cursor-pointer items-center gap-2 rounded text-[#1a1a1a] text-sm dark:text-[#fcfcfc]">
            <span className="font-medium">{running ? "Working…" : "Worked for 3 seconds"}</span>
          </Steps.Trigger>

          <Steps.Panel className="mt-2 flex flex-col gap-1.5 pl-1">
            {ROWS.map((row, index) => {
              const status = statusFor(index);
              return (
                <div key={row} className="flex items-center gap-2">
                  <Steps.Icon status={status} className={iconClass}>
                    {status === "complete" ? (
                      <IconCheck className="size-3.5" />
                    ) : status === "error" ? (
                      <IconX className="size-3.5" />
                    ) : status === "active" ? (
                      <IconLoader2 />
                    ) : (
                      <IconCircle className="size-3.5" />
                    )}
                  </Steps.Icon>

                  <Steps.Label status={status} className={labelClass}>
                    {row}
                  </Steps.Label>

                  {/* Visually hidden, and the only thing that speaks the status:
                      the icon is aria-hidden and colour announces nothing. */}
                  <Steps.Status status={status} />
                </div>
              );
            })}
          </Steps.Panel>
        </Steps.Item>
      </Steps.Root>

      <div className="flex justify-center">
        <button
          type="button"
          onClick={run}
          disabled={running}
          className="h-8 cursor-pointer rounded-full border border-[#e4e4e4] bg-white px-4 font-medium text-[#1a1a1a] text-sm transition-colors hover:bg-[#f4f4f4] disabled:cursor-default disabled:opacity-40 dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#fcfcfc] dark:hover:bg-[#232323]"
        >
          {running ? "Running…" : "Run again"}
        </button>
      </div>
    </div>
  );
};

// Every rule here keys off data-status. The package supplies the attribute and
// takes no view on what the values mean.
const iconClass =
  "grid size-4 shrink-0 place-items-center text-[#949494] data-[status=complete]:text-emerald-600 data-[status=active]:text-[#1a1a1a] data-[status=error]:text-red-600 dark:text-[#6f6f6f] dark:data-[status=complete]:text-emerald-400 dark:data-[status=active]:text-[#fcfcfc] dark:data-[status=error]:text-red-400";

const labelClass =
  "text-sm text-[#949494] data-[status=complete]:text-[#686868] data-[status=active]:text-[#1a1a1a] data-[status=active]:font-medium data-[status=error]:text-red-600 dark:text-[#6f6f6f] dark:data-[status=complete]:text-[#9b9b9b] dark:data-[status=active]:text-[#fcfcfc] dark:data-[status=error]:text-red-400";

const _glyph = (props: ComponentProps<"svg">) => ({
  viewBox: "0 0 16 16",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.6,
  strokeLinecap: "round" as const,
  strokeLinejoin: "round" as const,
  className: "size-3.5",
  "aria-hidden": true,
  ...props,
});
```

## Why the panel releases its height

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,
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`.

## 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 container. Ships the disclosure and status plumbing and no row
content: what a tool call or a reasoning step looks like is yours. Renders a
`<div>` element.

### Steps.Item

One node of the tree, and the unit that nests: an item's panel may hold further
items, so a timeline goes as deep as the run did. Renders a `<div>` element, 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

The row that expands an item. A real button, so the tree is plain sequential tab
order rather than a composite widget with its own keyboard model. Renders a
`<button>` element
(`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 collapsible body. Publishes its measured height while a transition runs and
releases it once settled open, so an open panel grows with content that arrives
inside it. 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

The row's status glyph, `aria-hidden` because colour and shape announce
nothing. Resolves `status` from its own prop, then the enclosing item. Renders a
`<span>` element.

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

The row's text. Resolves `status` the same way the icon does, so one attribute
drives both. Renders a `<span>` element.

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

The only part that speaks the status. Renders a visually hidden `<span>` saying
the resolved status string; pass `children` to localise the wording. Carries
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} />
