Skip to content
Seamless UI

Step List

Save

A compact checklist of an agent's plan on a connected rail, with queued, running, succeeded, failed, and skipped steps.

Category
Agent
Version
0.2.0
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Step List preview
  1. Search the docs, Done

    Found 3 pages

  2. Edit the limits table, Running

  3. Check links, Failed

    2 links returned 404

  4. Notify the team, Skipped

  5. Open a pull request, Queued

Installation

npx @seamlessui/cli add step-list

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add step-list --dry-run

Usage

step-list-example.tsx
import { StepList } from "@/components/seamless/agent/step-list";

export default function StepListExample() {
  return (
    <StepList
      className="w-full max-w-sm"
      steps={[
        { id: "1", label: "Search the docs", status: "succeeded", detail: "Found 3 pages" },
        { id: "2", label: "Edit the limits table", status: "running" },
        { id: "3", label: "Check links", status: "failed", detail: "2 links returned 404" },
        { id: "4", label: "Notify the team", status: "skipped" },
        { id: "5", label: "Open a pull request", status: "queued" },
      ]}
    />
  );
}

API

PropTypeDefaultDescription
steps{ id, label, status, detail?, meta? }[]โ€”Steps in order. detail shows a result or the error; meta is right-aligned metadata such as a time and duration.
StepStatusIcon({ status }) => JSXโ€”The status glyph on its own, also used by Run Timeline and the graph components.
StatusGlyph({ kind, size? }) => JSXโ€”The shared stroke glyphs: queued, running, success, failed, waiting, skipped. size is 16 or 20.

Accessibility

  • Each step has a visible text status and a screen-reader status after its label; glyphs are decorative.
  • Skipped steps are struck through and labeled, not only dimmed.
  • With reduced motion the spinner holds still and new steps, glyph swaps, and the check draw appear instantly.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/agent/step-list.tsxstep-list
  • Also installs agent-run@0.1.1registry dependency
  • npm packages: noneReact and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/step-list/0.2.0.json.

agent/step-list.tsxShow
agent/step-list.tsx
import type { ComponentProps, ReactNode } from "react";
import type { StepStatus } from "./agent-run";

const labels: Record<StepStatus, string> = {
  queued: "Queued",
  running: "Running",
  succeeded: "Done",
  failed: "Failed",
  skipped: "Skipped",
};

// Only running and failed rows color their label; the glyph already carries the rest.
const labelTone: Record<StepStatus, string> = {
  queued: "text-zinc-500",
  running: "text-sky-600 dark:text-sky-400",
  succeeded: "text-zinc-500",
  failed: "text-red-600 dark:text-red-400",
  skipped: "text-zinc-500",
};

export interface StepListItem {
  id: string;
  label: ReactNode;
  status: StepStatus;
  /** Secondary line, e.g. a result summary or the error. */
  detail?: ReactNode;
  /** Optional right-aligned metadata on the second line, e.g. a start time and duration. */
  meta?: ReactNode;
}

export interface StepListProps extends Omit<ComponentProps<"ol">, "children"> {
  steps: StepListItem[];
}

/**
 * A compact checklist of an agent's plan. Steps sit on a connected rail; each shows a
 * status glyph and a text status, and the rail turns green below finished steps.
 */
export function StepList({ steps, className, ...props }: StepListProps) {
  return (
    <ol className={["grid", className].filter(Boolean).join(" ")} {...props}>
      {steps.map((step, index) => (
        <li
          key={step.id}
          data-status={step.status}
          className="relative grid grid-cols-[1rem_minmax(0,1fr)] gap-x-3 pb-4 transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] last:pb-0 starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none"
        >
          {index < steps.length - 1 ? (
            <span
              aria-hidden="true"
              className={[
                "absolute top-[1.375rem] bottom-0.5 left-[7.5px] w-px rounded-full transition-colors duration-200 motion-reduce:transition-none",
                step.status === "succeeded" ? "bg-emerald-500/45" : "bg-zinc-950/10 dark:bg-white/12",
              ].join(" ")}
            />
          ) : null}
          <StepStatusIcon key={step.status} status={step.status} className="mt-0.5" />
          <div className="min-w-0">
            <div className="flex items-baseline justify-between gap-3">
              <p
                className={[
                  "min-w-0 text-sm break-words",
                  step.status === "skipped"
                    ? "text-zinc-500 line-through decoration-zinc-500/60"
                    : step.status === "queued"
                      ? "text-zinc-600 dark:text-zinc-400"
                      : "text-zinc-950 dark:text-white",
                  step.status === "running" ? "font-medium" : "",
                ].join(" ")}
              >
                {step.label}
                <span className="sr-only">, {labels[step.status]}</span>
              </p>
              <span
                aria-hidden="true"
                className={`shrink-0 text-xs transition-colors duration-200 motion-reduce:transition-none ${labelTone[step.status]}`}
              >
                {labels[step.status]}
              </span>
            </div>
            {step.detail != null || step.meta != null ? (
              <div className="mt-0.5 flex items-baseline justify-between gap-3">
                {step.detail != null ? (
                  <p className={step.status === "failed" ? "min-w-0 text-xs text-red-600 dark:text-red-400" : "min-w-0 text-xs text-zinc-500"}>
                    {step.detail}
                  </p>
                ) : (
                  <span />
                )}
                {step.meta != null ? <span className="shrink-0 font-mono text-[11px] text-zinc-500 tabular-nums">{step.meta}</span> : null}
              </div>
            ) : null}
          </div>
        </li>
      ))}
    </ol>
  );
}

/** The shared status shapes. Each one is paired with a text label wherever it is used. */
export type StatusGlyphKind = "queued" | "running" | "success" | "failed" | "waiting" | "skipped";

const glyphTone: Record<StatusGlyphKind, string> = {
  queued: "text-zinc-500",
  running: "text-sky-600 dark:text-sky-400",
  success: "text-emerald-600 dark:text-emerald-400",
  failed: "text-red-600 dark:text-red-400",
  waiting: "text-amber-600 dark:text-amber-400",
  skipped: "text-zinc-500",
};

const stepGlyph: Record<StepStatus, StatusGlyphKind> = {
  queued: "queued",
  running: "running",
  succeeded: "success",
  failed: "failed",
  skipped: "skipped",
};

/**
 * A 16px or 20px stroke glyph for an agent state. Remount it with `key={kind}` so the new
 * shape scales in (and the success check draws); both are instant with reduced motion.
 * The running arc is the only loop, and it holds still with reduced motion.
 */
export function StatusGlyph({ kind, size = 16, className = "" }: { kind: StatusGlyphKind; size?: 16 | 20; className?: string }) {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      // 1.2 in a 16 unit box drawn at 20px is a 1.5px stroke, matching the 16px glyph.
      strokeWidth={size === 20 ? 1.2 : 1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      data-glyph={kind}
      className={[
        size === 20 ? "size-5" : "size-4",
        "shrink-0 transition-[opacity,scale] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:scale-90 starting:opacity-0 motion-reduce:transition-none",
        glyphTone[kind],
        className,
      ].join(" ")}
    >
      {kind === "queued" ? (
        <circle cx="8" cy="8" r="6.25" strokeDasharray="2.15 2.75" />
      ) : kind === "running" ? (
        <>
          <circle cx="8" cy="8" r="6.25" strokeOpacity="0.2" />
          <path d="M8 1.75A6.25 6.25 0 1 1 1.75 8" className="origin-center motion-safe:animate-spin" />
        </>
      ) : (
        <>
          <circle cx="8" cy="8" r="6.25" />
          {kind === "success" ? (
            <path
              d="m5.35 8.25 1.75 1.75 3.55-3.75"
              pathLength={1}
              strokeDasharray="1"
              className="[stroke-dashoffset:0] transition-[stroke-dashoffset] delay-75 duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] starting:[stroke-dashoffset:1] motion-reduce:transition-none"
            />
          ) : kind === "failed" ? (
            <path d="m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8" />
          ) : kind === "waiting" ? (
            <path d="M6.6 5.9v4.2M9.4 5.9v4.2" />
          ) : (
            <path d="m5.6 10.4 4.8-4.8" />
          )}
        </>
      )}
    </svg>
  );
}

/** Status glyph for steps; remount with `key={status}` to play its entry transition. */
export function StepStatusIcon({ status, className = "" }: { status: StepStatus; className?: string }) {
  return <StatusGlyph kind={stepGlyph[status]} className={className} />;
}