# Step Status

> Vertical deployment-style step list with pending, running, done and failed states.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/step-status
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/step-status.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/step-status-demo.tsx"
"use client"

import * as React from "react"
import { Play, RotateCcw } from "lucide-react"

import { StepStatus, type StepState, type StepStatusItem } from "@/components/step-status"

const STEPS = [
  { id: "install", title: "Install dependencies", description: "pnpm install --frozen-lockfile", ms: 1100 },
  { id: "lint", title: "Lint & type-check", description: "eslint . && tsc --noEmit", ms: 1300 },
  { id: "test", title: "Run test suite", description: "248 tests across 36 files", ms: 1600 },
  { id: "build", title: "Build production bundle", description: "next build", ms: 1500 },
  { id: "deploy", title: "Deploy to edge network", description: "12 regions", ms: 1200 },
]

const btn =
  "inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50"

export default function StepStatusDemo() {
  // current: index of the running step, -1 = idle, STEPS.length = all done
  const [current, setCurrent] = React.useState(-1)
  const [failed, setFailed] = React.useState(false)
  const timers = React.useRef<number[]>([])

  React.useEffect(() => {
    const ids = timers.current
    return () => {
      ids.forEach((id) => window.clearTimeout(id))
    }
  }, [])

  function clearTimers() {
    timers.current.forEach((id) => window.clearTimeout(id))
    timers.current.length = 0
  }

  function schedule(index: number, failAt: number | null) {
    const id = window.setTimeout(() => {
      if (failAt === index) {
        setFailed(true)
        return
      }
      setCurrent(index + 1)
      if (index + 1 < STEPS.length) schedule(index + 1, failAt)
    }, STEPS[index].ms)
    timers.current.push(id)
  }

  function run(failAt: number | null) {
    clearTimers()
    setFailed(false)
    setCurrent(0)
    schedule(0, failAt)
  }

  function reset() {
    clearTimers()
    setFailed(false)
    setCurrent(-1)
  }

  const running = current >= 0 && current < STEPS.length && !failed

  const steps: StepStatusItem[] = STEPS.map((s, i) => {
    let status: StepState = "pending"
    if (i < current) status = "done"
    else if (i === current) status = failed ? "failed" : "running"
    return {
      id: s.id,
      title: s.title,
      description: failed && i === current ? "Process exited with code 1" : s.description,
      status,
    }
  })

  return (
    <div className="w-full max-w-sm rounded-2xl border bg-card p-5 shadow-sm">
      <div className="mb-5 flex items-center justify-between">
        <div>
          <p className="text-sm font-semibold">Deployment #1284</p>
          <p className="text-xs text-muted-foreground">
            {current === -1 && "Ready to deploy"}
            {running && "Deploying..."}
            {failed && "Deployment failed"}
            {current === STEPS.length && "Deployed successfully"}
          </p>
        </div>
      </div>
      <StepStatus steps={steps} />
      <div className="mt-6 flex flex-wrap gap-2">
        <button
          type="button"
          disabled={running}
          onClick={() => run(null)}
          className={`${btn} bg-primary text-primary-foreground hover:opacity-90`}
        >
          <Play className="size-4" /> Run deploy
        </button>
        <button
          type="button"
          disabled={running}
          onClick={() => run(2)}
          className={`${btn} border bg-background hover:bg-accent`}
        >
          Simulate failure
        </button>
        <button
          type="button"
          disabled={current === -1}
          onClick={reset}
          aria-label="Reset"
          className={`${btn} border bg-background px-3 hover:bg-accent`}
        >
          <RotateCcw className="size-4" />
        </button>
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/step-status.tsx"
import * as React from "react"
import { Check, Circle, Loader2, X } from "lucide-react"

import { cn } from "@/lib/utils"

export type StepState = "pending" | "running" | "done" | "failed"

export interface StepStatusItem {
  id: string
  title: React.ReactNode
  description?: React.ReactNode
  status: StepState
  /** Right-aligned meta text, e.g. a duration */
  meta?: React.ReactNode
}

export interface StepStatusProps extends React.ComponentProps<"ol"> {
  steps: StepStatusItem[]
}

const stateLabel: Record<StepState, string> = {
  pending: "Pending",
  running: "In progress",
  done: "Completed",
  failed: "Failed",
}

function StepIcon({ status }: { status: StepState }) {
  switch (status) {
    case "done":
      return (
        <span className="flex size-7 items-center justify-center rounded-full bg-emerald-500 text-white">
          <Check className="size-4" strokeWidth={3} />
        </span>
      )
    case "failed":
      return (
        <span className="flex size-7 items-center justify-center rounded-full bg-destructive text-white">
          <X className="size-4" strokeWidth={3} />
        </span>
      )
    case "running":
      return (
        <span className="flex size-7 items-center justify-center rounded-full border border-primary/40 bg-primary/10 text-primary">
          <Loader2 className="size-4 animate-spin motion-reduce:[animation-duration:3s]" />
        </span>
      )
    default:
      return (
        <span className="flex size-7 items-center justify-center rounded-full border bg-background text-muted-foreground/60">
          <Circle className="size-3" />
        </span>
      )
  }
}

export function StepStatus({ steps, className, ...props }: StepStatusProps) {
  return (
    <ol className={cn("w-full", className)} {...props}>
      {steps.map((step, i) => {
        const last = i === steps.length - 1
        return (
          <li
            key={step.id}
            aria-current={step.status === "running" ? "step" : undefined}
            className="relative flex gap-3 pb-6 last:pb-0"
          >
            {!last && (
              <span
                aria-hidden
                className={cn(
                  "absolute left-3.5 top-8 -ml-px h-[calc(100%-2rem)] w-0.5 rounded-full transition-colors duration-500",
                  step.status === "done" ? "bg-emerald-500" : "bg-border"
                )}
              />
            )}
            <div className="relative z-10 shrink-0">
              <StepIcon status={step.status} />
            </div>
            <div className="min-w-0 flex-1 pt-0.5">
              <div className="flex items-baseline justify-between gap-3">
                <p
                  className={cn(
                    "text-sm font-medium",
                    step.status === "pending" && "text-muted-foreground",
                    step.status === "failed" && "text-destructive"
                  )}
                >
                  {step.title}
                  <span className="sr-only"> - {stateLabel[step.status]}</span>
                </p>
                {step.meta && <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{step.meta}</span>}
              </div>
              {step.description && <p className="mt-0.5 text-xs text-muted-foreground">{step.description}</p>}
            </div>
          </li>
        )
      })}
    </ol>
  )
}
```
