# Vertical Stepper

> A vertical multi-step progress navigator with connectors, error state, expandable content and clickable steps.

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

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/vertical-stepper.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/vertical-stepper-demo.tsx"
"use client"

import * as React from "react"

import { VerticalStepper, type VerticalStep } from "@/components/vertical-stepper"

const inputClass =
  "h-9 w-full min-w-0 rounded-lg border bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-4 focus-visible:ring-ring/40"

const steps: VerticalStep[] = [
  { title: "Create account", description: "ada@northwind.dev" },
  { title: "Verify domain", description: "We couldn't find the TXT record yet. Retry in a few minutes.", error: true },
  {
    title: "Invite your team",
    description: "Teammates get access right away",
    content: (
      <div className="grid gap-2">
        <label htmlFor="stepper-invite" className="text-xs font-medium text-foreground">
          Email addresses
        </label>
        <input id="stepper-invite" type="email" placeholder="grace@northwind.dev" className={inputClass} />
        <p className="text-xs">Separate multiple addresses with commas. You can always invite more people later.</p>
      </div>
    ),
  },
  { title: "Launch workspace", description: "Review and go live" },
]

const button = "h-9 rounded-lg px-4 text-sm font-medium transition-colors focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40"

export default function VerticalStepperDemo() {
  const [step, setStep] = React.useState(2)

  return (
    <div className="flex w-full max-w-md flex-col gap-6">
      <VerticalStepper aria-label="Onboarding progress" steps={steps} current={step} onStepChange={setStep} clickable />
      <div className="flex items-center justify-between gap-3 rounded-xl border bg-card p-3">
        <p className="min-w-0 truncate text-sm text-muted-foreground">
          Step {step + 1} of {steps.length}
        </p>
        <div className="flex gap-2">
          <button type="button" onClick={() => setStep((s) => s - 1)} disabled={step === 0} className={`${button} border hover:bg-accent`}>
            Back
          </button>
          <button
            type="button"
            onClick={() => setStep((s) => s + 1)}
            disabled={step === steps.length - 1}
            className={`${button} bg-primary text-primary-foreground hover:opacity-90`}
          >
            Next
          </button>
        </div>
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/vertical-stepper.tsx"
"use client"

import * as React from "react"
import { Check, CircleAlert } from "lucide-react"

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

export interface VerticalStep {
  title: string
  description?: string
  /** Extra content revealed under the step while it is the current one */
  content?: React.ReactNode
  /** Marks the step as failed; shown with an alert icon */
  error?: boolean
}

export interface VerticalStepperProps extends Omit<React.ComponentProps<"ol">, "onChange"> {
  steps: VerticalStep[]
  /** Zero-based index of the current step (controlled) */
  current?: number
  defaultCurrent?: number
  onStepChange?: (step: number) => void
  /** Let users jump to any step by clicking it */
  clickable?: boolean
}

type StepState = "complete" | "current" | "upcoming" | "error"

const stateLabel: Record<StepState, string> = {
  complete: "Completed",
  current: "Current step",
  upcoming: "Upcoming",
  error: "Has an error",
}

export function VerticalStepper({
  steps,
  current: currentProp,
  defaultCurrent = 0,
  onStepChange,
  clickable = false,
  className,
  ...props
}: VerticalStepperProps) {
  const baseId = React.useId()
  const [inner, setInner] = React.useState(defaultCurrent)
  const current = currentProp ?? inner

  const go = (index: number) => {
    if (currentProp === undefined) setInner(index)
    onStepChange?.(index)
  }

  return (
    <ol className={cn("flex flex-col", className)} {...props}>
      {steps.map((step, index) => {
        const isCurrent = index === current
        const state: StepState = step.error ? "error" : isCurrent ? "current" : index < current ? "complete" : "upcoming"
        const last = index === steps.length - 1
        const hasContent = !!step.content
        const open = isCurrent && hasContent
        const header = (
          <>
            <span
              aria-hidden
              className={cn(
                "relative z-10 flex size-9 shrink-0 items-center justify-center rounded-full border-2 text-sm font-medium transition-all duration-300 motion-reduce:transition-none",
                state === "complete" && "border-primary bg-primary text-primary-foreground",
                state === "current" && "border-primary bg-background text-primary ring-4 ring-primary/15",
                state === "upcoming" && "border-border bg-background text-muted-foreground",
                state === "error" && "border-destructive bg-destructive/10 text-destructive",
                step.error && isCurrent && "ring-4 ring-destructive/15"
              )}
            >
              {state === "complete" ? (
                <Check className="size-4 animate-in zoom-in-50 motion-reduce:animate-none" strokeWidth={3} />
              ) : state === "error" ? (
                <CircleAlert className="size-4.5" />
              ) : (
                index + 1
              )}
            </span>
            <span className="flex min-w-0 flex-1 flex-col gap-0.5 pt-1.5">
              <span className={cn("text-sm font-medium", state === "upcoming" && "text-muted-foreground", state === "error" && "text-destructive")}>
                {step.title}
                <span className="sr-only">{`, ${stateLabel[state]}`}</span>
              </span>
              {step.description && (
                <span className={cn("text-xs", state === "error" ? "text-destructive/80" : "text-muted-foreground")}>{step.description}</span>
              )}
            </span>
          </>
        )
        return (
          <li
            key={step.title}
            aria-current={isCurrent ? "step" : undefined}
            data-state={state}
            className="relative pb-8 last:pb-0"
          >
            {!last && (
              <span aria-hidden className="absolute top-10 bottom-1 left-[17px] w-0.5 overflow-hidden bg-border">
                <span
                  className={cn(
                    "absolute inset-0 origin-top bg-primary transition-transform duration-500 motion-reduce:transition-none",
                    index < current && !step.error ? "scale-y-100" : "scale-y-0"
                  )}
                />
              </span>
            )}
            {clickable ? (
              <button
                type="button"
                onClick={() => go(index)}
                aria-controls={hasContent ? `${baseId}-content-${index}` : undefined}
                className="-m-1 flex w-[calc(100%+0.5rem)] items-start gap-4 rounded-xl p-1 text-left outline-none focus-visible:ring-4 focus-visible:ring-ring/40"
              >
                {header}
              </button>
            ) : (
              <div className="flex items-start gap-4">{header}</div>
            )}
            {hasContent && (
              <div
                id={`${baseId}-content-${index}`}
                inert={!open}
                className={cn(
                  "grid transition-[grid-template-rows,opacity] duration-300 ease-out motion-reduce:transition-none",
                  open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
                )}
              >
                <div className="min-h-0 overflow-hidden">
                  {/* Side/bottom padding leaves room for focus rings inside the clipped area */}
                  <div className="ml-12 px-1 pt-3 pb-1 text-sm text-muted-foreground">{step.content}</div>
                </div>
              </div>
            )}
          </li>
        )
      })}
    </ol>
  )
}
```
