Skip to content

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.

On this page

Example

Loading example…
Steps.tsxtsx
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.

Loading example…
steps.generated-surface.tsxtsx
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.

  1. Steps

    Current-step provider. Does not add a DOM element.

  2. StepsList

    Native ordered list; the sequence itself is meaningful. Owns a DOM element.

  3. 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.

  4. StepsTriggerOptional

    Optional button that jumps back to its step. Owns a DOM element.

  5. StepsPanelOptional

    Content for one step; hidden unless current. Owns a DOM element.

Step by step

  1. 1

    Add the main part

    Start Steps with the value of the first step.

  2. 2

    Add the supporting parts

    Put one StepsItem per step inside StepsList, and give each StepsPanel the matching value.

  3. 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

Importtsx
import { Steps, StepsItem, StepsList, StepsPanel, StepsTrigger } from "@comp0/react";

Steps

Context only

Current-step provider.

PropTypeDescription
valuestringControlled current step.
defaultValuestringInitial current step.
onChange(value: string) => voidReceives the next step value.
asElementTypeRenders a wrapper element around the whole process.

StepsList

DOM element

Native ordered list; the sequence itself is meaningful.

PropTypeDescription
aria-labelstringNames the process for assistive technology.

StepsItem

DOM element

List item for one step; items register in document order, so each knows its position and whether it is completed.

PropTypeDescription
valuestringIdentity that pairs this item with its panel.
childrenReactNode | (state) => ReactNodeReceives { step, current, completed } for numbering and painting.

StepsTrigger

OptionalDOM element

Optional button that jumps back to its step.

PropTypeDescription
disabledbooleanKeeps the step visible but not activatable.
asElementTypeRenders another element with button behavior attached.

StepsPanel

OptionalDOM element

Content for one step; hidden unless current.

PropTypeDescription
valuestringThe 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 hookMeaning
[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 hookMeaning
[aria-current]The trigger belongs to the current step.

Keep exploring