Steps
A collapsible timeline of a run — reasoning, tool calls, and answered questions as chronological steps.
What changed about refs in React 19?
Function components now take ref as a plain prop, so most forwardRef wrappers can go. Ref callbacks can also return a cleanup function
"use client";
import { Steps } from "@intentface/chat/steps";
import { Check, ChevronDown, Circle } from "@keyline-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. A rail
// joins the step icons (RAIL_CLASS).
//
// The question above and the half-written reply below are only context: the
// steps sit inside an assistant turn, the way they do in a chat.
export const Basic = () => (
// Sized for the fully expanded state, so opening and closing steps never
// shifts the page around the demo.
<div className="flex min-h-80 w-full max-w-xl flex-col gap-4">
<p className="max-w-[80%] self-end rounded-[20px] bg-white px-3.5 py-1.5 text-sm text-zinc-900 leading-6 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_rgb(0_0_0/0.04)] dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16)]">
What changed about refs in React 19?
</p>
<Steps.Root className="w-full">
<Steps.Item defaultOpen>
<Steps.Trigger className={`${TRIGGER_CLASS} font-medium`}>
<span>Worked for 3 seconds</span>
<ChevronDown className="size-[15px] shrink-0 -rotate-90 transition-transform group-data-open/steps-trigger:rotate-0" />
</Steps.Trigger>
<Steps.Panel className={PANEL_CLASS}>
{/* Each step is a column: a piece of rail, then icon and label in a row.
The first step has nothing above it to connect to. */}
<div className="flex h-7 items-center gap-2.5">
<Steps.Icon className={ICON_CLASS}>
<Check className="size-[15px]" />
</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>
<span aria-hidden="true" className={RAIL_CLASS} />
<Steps.Trigger className={TRIGGER_CLASS}>
<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">
<Check className="size-[15px]" />
</span>
<ChevronDown className="absolute size-[15px] 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-1.5 text-[13px] text-zinc-500 leading-5 dark:text-zinc-400">
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 flex-col">
<span aria-hidden="true" className={RAIL_CLASS} />
<div className="flex h-7 items-center gap-2.5">
<Steps.Icon status="active" className={ICON_CLASS}>
<Circle className="size-[15px] animate-pulse" />
</Steps.Icon>
<Steps.Label status="active" className={LABEL_CLASS}>
Writing the answer
</Steps.Label>
</div>
</div>
</Steps.Panel>
</Steps.Item>
</Steps.Root>
<p className="text-sm text-zinc-700 leading-6 dark:text-zinc-300">
Function components now take <code className="font-mono text-[13px]">ref</code> as a plain
prop, so most <code className="font-mono text-[13px]">forwardRef</code> wrappers can go. Ref
callbacks can also return a cleanup function
<span className="ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 animate-pulse rounded-full bg-zinc-400 dark:bg-zinc-500" />
</p>
</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 h-7 w-full cursor-pointer items-center gap-2.5 rounded-md text-[13px] text-zinc-500 transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-400 dark:hover:text-zinc-100";
// 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-[7px] in-data-nested:border-l in-data-nested:border-zinc-950/10 in-data-nested:pl-[17px] dark:in-data-nested:border-white/10";
// A piece of rail above a step, centred on the 15px icon column. An open nested
// panel draws the same line down its left edge, so the rail stays unbroken.
const RAIL_CLASS = "ml-[7px] block h-2 w-px bg-zinc-950/10 dark:bg-white/10";
// Status is inherited from the enclosing item and surfaced as data-status, so
// one class string covers every state.
const ICON_CLASS =
"flex size-[15px] shrink-0 items-center justify-center data-[status=complete]:text-zinc-500 data-[status=active]:text-zinc-900 data-[status=pending]:text-zinc-400 dark:data-[status=complete]:text-zinc-400 dark:data-[status=active]:text-zinc-100 dark:data-[status=pending]:text-zinc-500";
const LABEL_CLASS =
"text-left text-[13px] data-[status=complete]:text-zinc-700 data-[status=active]:font-medium data-[status=active]:text-zinc-900 data-[status=pending]:text-zinc-400 dark:data-[status=complete]:text-zinc-300 dark:data-[status=active]:text-zinc-100 dark:data-[status=pending]:text-zinc-500";Usage guidelines
- Recursive disclosure tree — every node is a
Steps.Itemwith aTriggerand aPanel, and panels can hold further items, so timelines nest arbitrarily. - Status-driven — each item's
status(complete/active/pending) flows to itsIconandLabelvia context; active items open by default. - Nesting — a nested item surfaces
data-nestedfor the indent rail; a static row is anIconand aLabelin 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, which is also why an open panel keeps growing.
- Get started — see 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:
<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.
"use client";
import { Steps } from "@intentface/chat/steps";
import { Check, ChevronDown, LoaderCircle, X } from "@keyline-icons/react";
import { 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" below is not a feature; it is a string this demo invented and then
* styled — here with nothing but its glyph. Run it and watch each step arrive
* active and settle as complete, with one failing on the way.
*
* The entrance is plain CSS: each step's row grows from 0fr to 1fr, then its icon
* fades in, then its label, all from @starting-style (Tailwind's `starting:` variant).
*/
// A step stays active this long before it settles, and the next one arrives
// after a short pause.
const ACTIVE_MS = 1400;
const PAUSE_MS = 400;
export const Status = () => {
// Steps that have appeared, and steps that have settled.
const [appeared, setAppeared] = useState(ROWS.length);
const [settled, setSettled] = useState(ROWS.length);
// Bumped per run so every row remounts and plays its entrance again.
const [run, setRun] = useState(0);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const start = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
setRun((current) => current + 1);
setAppeared(0);
setSettled(0);
ROWS.forEach((_, index) => {
const start = index * (ACTIVE_MS + PAUSE_MS);
timers.current.push(setTimeout(() => setAppeared(index + 1), start));
timers.current.push(setTimeout(() => setSettled(index + 1), start + ACTIVE_MS));
});
};
const statusFor = (index: number) => {
if (index >= settled) return "active";
return index === 2 ? "error" : "complete";
};
const running = settled < ROWS.length;
const visible = ROWS.slice(0, appeared);
return (
<div className="flex w-full max-w-lg flex-col gap-4">
{/* Reserves the finished list's height (28px header + four 28px steps with
8px of rail between them), so the button below stays put while steps
arrive or the list collapses. */}
<Steps.Root className="min-h-[164px] w-full">
<Steps.Item defaultOpen>
<Steps.Trigger className="group/steps-trigger flex h-7 w-full cursor-pointer items-center gap-2.5 rounded-md text-[13px] text-zinc-500 transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-400 dark:hover:text-zinc-100">
<span className="font-medium">{running ? "Working…" : "Worked for 3 seconds"}</span>
<ChevronDown className="size-[15px] shrink-0 -rotate-90 transition-transform group-data-open/steps-trigger:rotate-0" />
</Steps.Trigger>
{/* Collapsing animates the height from --panel-height, which the panel
publishes while it transitions; [&>*]:shrink-0 keeps the measure honest. */}
<Steps.Panel className="flex h-(--panel-height) flex-col overflow-hidden transition-[height] duration-200 ease-out data-ending-style:h-0 data-starting-style:h-0 [&>*]:shrink-0">
{visible.map((row, index) => {
const status = statusFor(index);
return (
// The row grows first (0–300ms), then the icon fades in (300–600ms),
// then the label (600–900ms).
<div
key={`${run}-${row}`}
className="grid grid-rows-[1fr] transition-[grid-template-rows] duration-300 ease-out starting:grid-rows-[0fr]"
>
<div className="flex min-h-0 flex-col overflow-hidden">
{/* A piece of rail joins this step to the one above. */}
{index > 0 && (
<span
aria-hidden="true"
className="ml-[7px] block h-2 w-px shrink-0 bg-zinc-950/10 dark:bg-white/10"
/>
)}
<div className="flex h-7 shrink-0 items-center gap-2.5">
{/* Every rule here keys off data-status. The package supplies the
attribute and takes no view on what the values mean. */}
<Steps.Icon
status={status}
className="grid size-[15px] shrink-0 place-items-center text-zinc-500 transition-opacity delay-300 duration-300 ease-out starting:opacity-0 data-[status=active]:text-zinc-900 dark:text-zinc-400 dark:data-[status=active]:text-zinc-100"
>
{status === "complete" ? (
<Check className="size-[15px]" />
) : status === "error" ? (
<X className="size-[15px]" />
) : (
<LoaderCircle className="size-[15px] animate-spin" />
)}
</Steps.Icon>
<Steps.Label
status={status}
className="text-[13px] text-zinc-700 transition-[opacity,translate] delay-[600ms] duration-300 ease-out starting:-translate-x-2 starting:opacity-0 data-[status=active]:font-medium data-[status=active]:text-zinc-900 data-[status=error]:text-zinc-500 dark:text-zinc-300 dark:data-[status=active]:text-zinc-100 dark:data-[status=error]:text-zinc-400"
>
{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>
</div>
</div>
);
})}
</Steps.Panel>
</Steps.Item>
</Steps.Root>
<div className="flex justify-center">
<button
type="button"
onClick={start}
disabled={running}
className="h-8 cursor-pointer rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-3.5 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]"
>
{running ? "Running…" : "Run again"}
</button>
</div>
</div>
);
};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) 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".
| Prop | Type | Default | Details |
|---|---|---|---|
status | string | "complete" | |
defaultOpen | boolean | status === active | |
open | boolean | — | |
onOpenChange | (open: boolean) => void | — |
| Attribute | Description |
|---|---|
data-steps-item | |
data-status | |
data-nested | |
data-open | |
data-closed |
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.
| Prop | Type | Default | Details |
|---|---|---|---|
keepMounted | boolean | false |
| Attribute | Description |
|---|---|
data-steps-panel | |
data-open | |
data-closed | |
data-starting-style | |
data-ending-style |
| CSS variable | Description |
|---|---|
--panel-height |
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.
| Prop | Type | Default | Details |
|---|---|---|---|
status | string | — |
| Attribute | Description |
|---|---|
data-steps-icon | |
data-status |
Steps.Label
The row's text. Resolves status the same way the icon does, so one attribute
drives both. Renders a <span> element.
| Prop | Type | Default | Details |
|---|---|---|---|
status | string | — |
| Attribute | Description |
|---|---|
data-steps-label | |
data-status |
Steps.Status
The status as text for assistive technology. Renders a visually hidden <span>
holding the resolved status string; pass children to localise the wording,
and style/className to override the hiding. It is not a live region: a
screen reader reads it when it reaches the row, but a change is not announced
as it happens. Wrap the list in your own role="status" region if it should be.
| Prop | Type | Default | Details |
|---|---|---|---|
status | string | — | |
children | ReactNode | the resolved status string |
| Attribute | Description |
|---|---|
data-steps-status |