Navigation
Steps
A numbered trail through a fixed process, showing one panel at a time.
When to use it: Use it for checkout, signup, and other flows people finish in order.
Example
import { useState } from "react";
import { Button, Steps, StepsItem, StepsList, StepsPanel, StepsTrigger } from "@comp0/react";
import { CheckIcon } from "@heroicons/react/16/solid";
const checkout = [
{ value: "shipping", title: "Shipping" },
{ value: "payment", title: "Payment" },
{ value: "review", title: "Review" },
];
export function Example() {
const [current, setCurrent] = useState("shipping");
const position = checkout.findIndex((entry) => entry.value === current);
const previous = checkout[position - 1];
const next = checkout[position + 1];
return (
<Steps as="div" className="w-full max-w-md" value={current} onChange={setCurrent}>
<StepsList aria-label="Checkout" className="flex items-center gap-3">
{checkout.map((entry) => (
<StepsItem
key={entry.value}
value={entry.value}
className="group flex items-center gap-3 not-last:flex-1 not-last:after:h-px not-last:after:flex-1 not-last:after:bg-zinc-950/10 dark:not-last:after:bg-white/10"
>
{({ step }) => (
<StepsTrigger className="flex min-h-11 flex-col items-center gap-2 text-base sm:flex-row text-zinc-500 data-completed:text-zinc-700 data-current:text-zinc-950 sm:text-sm dark:text-zinc-400 dark:data-completed:text-zinc-300 dark:data-current:text-white">
<span className="grid size-6 place-items-center rounded-full border border-zinc-950/20 text-xs group-data-completed:border-teal-700 group-data-completed:bg-teal-700 group-data-completed:text-white group-data-current:border-teal-700 group-data-current:text-teal-700 dark:border-white/20 dark:group-data-completed:border-teal-400 dark:group-data-completed:bg-teal-400 dark:group-data-completed:text-zinc-950 dark:group-data-current:border-teal-400 dark:group-data-current:text-teal-300">
<CheckIcon
className="hidden size-4 group-data-completed:block"
aria-hidden="true"
/>
<span className="group-data-completed:hidden">{step}</span>
</span>
{entry.title}
</StepsTrigger>
)}
</StepsItem>
))}
</StepsList>
<StepsPanel
className="pt-4 text-base text-zinc-600 sm:text-sm dark:text-zinc-400"
value="shipping"
>
Where should we send your order?
</StepsPanel>
<StepsPanel
className="pt-4 text-base text-zinc-600 sm:text-sm dark:text-zinc-400"
value="payment"
>
How would you like to pay?
</StepsPanel>
<StepsPanel
className="pt-4 text-base text-zinc-600 sm:text-sm dark:text-zinc-400"
value="review"
>
Check everything before placing the order.
</StepsPanel>
<div className="flex gap-2 pt-4">
<Button
className="rounded px-3 py-2.5 text-base text-zinc-700 ring-1 ring-zinc-950/10 disabled:opacity-40 sm:py-2 sm:text-sm dark:text-zinc-300 dark:ring-white/10"
disabled={!previous}
onClick={() => previous && setCurrent(previous.value)}
>
Back
</Button>
<Button
className="rounded bg-teal-700 px-3 py-2.5 text-base text-white disabled:opacity-40 sm:py-2 sm:text-sm dark:bg-teal-400 dark:text-zinc-950"
disabled={!next}
onClick={() => next && setCurrent(next.value)}
>
Continue
</Button>
</div>
</Steps>
);
}Constrained generated UI
Stream a typed description into a trusted local registry that renders headings, summaries, steps, and actions without executing arbitrary generated code.
import { useEffect, useState } from "react";
import {
Button,
Status,
Steps,
StepsItem,
StepsList,
StepsPanel,
StepsTrigger,
} from "@comp0/react";
type SurfaceNode =
| { id: string; kind: "heading"; text: string }
| { id: string; kind: "summary"; text: string }
| {
id: string;
kind: "plan";
steps: readonly { value: string; title: string; detail: string }[];
}
| { id: string; kind: "actions" };
const approvedSurface: readonly SurfaceNode[] = [
{ id: "heading", kind: "heading", text: "Release proposal" },
{
id: "summary",
kind: "summary",
text: "The agent proposed a three-step release using only components registered by the app.",
},
{
id: "plan",
kind: "plan",
steps: [
{ value: "review", title: "Review", detail: "Review 14 changed files and two migrations." },
{ value: "test", title: "Test", detail: "Run unit, browser, and package-consumer checks." },
{ value: "publish", title: "Publish", detail: "Publish after an explicit human approval." },
],
},
{ id: "actions", kind: "actions" },
];
export function Example() {
const [visibleNodes, setVisibleNodes] = useState(0);
const [generating, setGenerating] = useState(false);
const [currentStep, setCurrentStep] = useState("review");
const [decision, setDecision] = useState("");
useEffect(() => {
if (!generating) return;
if (visibleNodes >= approvedSurface.length) {
setGenerating(false);
return;
}
const timer = setTimeout(() => setVisibleNodes((current) => current + 1), 350);
return () => clearTimeout(timer);
}, [generating, visibleNodes]);
const generateSurface = () => {
setVisibleNodes(0);
setCurrentStep("review");
setDecision("");
setGenerating(true);
};
let announcement = "Choose Generate interface to start.";
if (generating) announcement = "Generating interface from approved components.";
if (!generating && visibleNodes === approvedSurface.length) announcement = "Interface ready.";
if (decision) announcement = decision;
return (
<section aria-labelledby="generated-ui-title" className="w-full max-w-xl">
<div className="flex flex-col items-start gap-4 sm:flex-row sm:justify-between">
<div>
<h2 id="generated-ui-title" className="font-semibold text-zinc-950 dark:text-white">
Release interface generator
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
A typed registry maps streamed descriptions to trusted components—never arbitrary code.
</p>
</div>
<Button
disabled={generating}
className="shrink-0 rounded-lg bg-teal-700 px-3 py-2 text-sm font-medium text-white outline-teal-600 focus-visible:outline-2 disabled:opacity-50 dark:bg-teal-400 dark:text-zinc-950 dark:outline-teal-300"
onClick={generateSurface}
>
{visibleNodes === 0 ? "Generate interface" : "Generate again"}
</Button>
</div>
<Status className="mt-3 text-sm text-zinc-600 dark:text-zinc-400" aria-atomic="true">
{announcement}
</Status>
<div
aria-busy={generating || undefined}
className="mt-4 min-h-72 rounded-xl border border-zinc-950/10 bg-white p-4 dark:border-white/10 dark:bg-zinc-900"
>
{visibleNodes === 0 && !generating && (
<p className="grid min-h-56 place-items-center text-sm text-zinc-500 dark:text-zinc-400">
Generated components appear here.
</p>
)}
<div className="grid gap-4">
{approvedSurface.slice(0, visibleNodes).map((node) => {
if (node.kind === "heading") {
return (
<h3 key={node.id} className="text-lg font-semibold text-zinc-950 dark:text-white">
{node.text}
</h3>
);
}
if (node.kind === "summary") {
return (
<p key={node.id} className="text-sm text-zinc-600 dark:text-zinc-300">
{node.text}
</p>
);
}
if (node.kind === "plan") {
return (
<Steps key={node.id} as="div" value={currentStep} onChange={setCurrentStep}>
<StepsList aria-label="Generated release plan" className="flex gap-2">
{node.steps.map((step) => (
<StepsItem key={step.value} value={step.value} className="flex-1">
<StepsTrigger className="w-full rounded-lg border border-zinc-950/10 px-2 py-2 text-sm text-zinc-600 outline-teal-600 data-current:border-teal-700 data-current:bg-teal-50 data-current:text-teal-950 focus-visible:outline-2 dark:border-white/10 dark:text-zinc-300 dark:data-current:border-teal-400 dark:data-current:bg-teal-950 dark:data-current:text-teal-50 dark:outline-teal-400">
{step.title}
</StepsTrigger>
</StepsItem>
))}
</StepsList>
{node.steps.map((step) => (
<StepsPanel
key={step.value}
value={step.value}
className="pt-3 text-sm text-zinc-600 dark:text-zinc-300"
>
{step.detail}
</StepsPanel>
))}
</Steps>
);
}
return (
<div
key={node.id}
className="flex flex-wrap gap-2 border-t border-zinc-950/10 pt-4 dark:border-white/10"
>
<Button
className="rounded-lg bg-emerald-700 px-3 py-2 text-sm font-medium text-white outline-emerald-700 focus-visible:outline-2 dark:bg-emerald-400 dark:text-zinc-950 dark:outline-emerald-300"
onClick={() => setDecision("Release proposal approved.")}
>
Approve plan
</Button>
<Button
className="rounded-lg border border-zinc-950/15 px-3 py-2 text-sm font-medium outline-teal-600 focus-visible:outline-2 dark:border-white/15 dark:outline-teal-400"
onClick={() => setDecision("Changes requested.")}
>
Request changes
</Button>
</div>
);
})}
</div>
</div>
</section>
);
}Anatomy
Dashed frames are invisible state providers; shaded shapes own real DOM. Numbered pins match the list below.
A wireframe sketch of the assembled component. Each numbered marker matches a part in the list that follows.
Steps
Current-step provider. Does not add a DOM element.
StepsList
Native ordered list; the sequence itself is meaningful. Owns a DOM element.
StepsItem
List item for one step; items register in document order, so each knows its position and whether it is completed. Owns a DOM element.
StepsTriggerOptional
Optional button that jumps back to its step. Owns a DOM element.
StepsPanelOptional
Content for one step; hidden unless current. Owns a DOM element.
Step by step
- 1
Add the main part
Start Steps with the value of the first step.
- 2
Add the supporting parts
Put one StepsItem per step inside StepsList, and give each StepsPanel the matching value.
- 3
Make the behavior clear
Advance the value from your own Continue button; every item before it marks itself completed.
Exampletsx <Steps defaultValue="shipping"> <StepsList> <StepsItem value="shipping"> <StepsTrigger>Shipping</StepsTrigger> </StepsItem> </StepsList> <StepsPanel value="shipping">Address form</StepsPanel> </Steps>;
Keyboard
- ⇥
- Moves focus through the step triggers.
- ↵
- Activates the focused step.
- Space
- Activates the focused step.
Forms and accessibility
No native form behavior; the panels hold the real form fields.
Accessibility checklist
- StepsTrigger marks the current step with aria-current="step"; do not duplicate that state manually.
- Each panel is labelled by its step item, so keep the visible step names meaningful.
- Only render StepsTrigger for steps that are safe to return to; use plain text for locked steps.
- Show completed and current states with more than color; the numbered circles or a checkmark carry the order.
API reference
import { Steps, StepsItem, StepsList, StepsPanel, StepsTrigger } from "@comp0/react";Steps
Context onlyCurrent-step provider.
| Prop | Type | Description |
|---|---|---|
value | string | Controlled current step. |
defaultValue | string | Initial current step. |
onChange | (value: string) => void | Receives the next step value. |
as | ElementType | Renders a wrapper element around the whole process. |
StepsList
DOM elementNative ordered list; the sequence itself is meaningful.
| Prop | Type | Description |
|---|---|---|
aria-label | string | Names the process for assistive technology. |
StepsItem
DOM elementList item for one step; items register in document order, so each knows its position and whether it is completed.
| Prop | Type | Description |
|---|---|---|
value | string | Identity that pairs this item with its panel. |
children | ReactNode | (state) => ReactNode | Receives { step, current, completed } for numbering and painting. |
StepsTrigger
OptionalDOM elementOptional button that jumps back to its step.
| Prop | Type | Description |
|---|---|---|
disabled | boolean | Keeps the step visible but not activatable. |
as | ElementType | Renders another element with button behavior attached. |
StepsPanel
OptionalDOM elementContent for one step; hidden unless current.
| Prop | Type | Description |
|---|---|---|
value | string | The step this panel belongs to. |
Style hooks
Attributes that appear while a state is true. Target them with Tailwind data variants such as data-open:bg-zinc-100, or with any CSS selector.
StepsItem
| Style hook | Meaning |
|---|---|
[data-current] | This is the current step. |
[data-completed] | This step comes before the current one. |
[data-step] | The item's 1-based position, for numbering. |
StepsTrigger
| Style hook | Meaning |
|---|---|
[aria-current] | The trigger belongs to the current step. |