# Stepper

> Multi-step progress indicator, horizontal or vertical, with clickable completed steps.

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

## Install

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

import * as React from "react"

import { Stepper } from "@/components/stepper"

const steps = [
  { title: "Account", description: "Email & password" },
  { title: "Workspace", description: "Name your team" },
  { title: "Invite", description: "Add teammates" },
  { title: "Done", description: "Start building" },
]

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

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

  return (
    <div className="flex w-full max-w-2xl flex-col gap-8">
      <Stepper steps={steps} step={step} onStepChange={setStep} />
      <div className="flex items-center justify-between rounded-xl border bg-card p-4">
        <p className="text-sm text-muted-foreground">
          Step {step + 1} of {steps.length}: <span className="font-medium text-foreground">{steps[step].title}</span>
        </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`}
          >
            Continue
          </button>
        </div>
      </div>
    </div>
  )
}
```

## Source

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

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

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

export interface StepperStep {
  title: string
  description?: string
}

export interface StepperProps extends Omit<React.ComponentProps<"ol">, "onChange"> {
  steps: StepperStep[]
  /** Zero-based index of the current step */
  step?: number
  defaultStep?: number
  onStepChange?: (step: number) => void
  orientation?: "horizontal" | "vertical"
  /** Allow jumping back to completed steps */
  clickable?: boolean
}

export function Stepper({
  steps,
  step: stepProp,
  defaultStep = 0,
  onStepChange,
  orientation = "horizontal",
  clickable = true,
  className,
  ...props
}: StepperProps) {
  const [innerStep, setInnerStep] = React.useState(defaultStep)
  const current = stepProp ?? innerStep
  const vertical = orientation === "vertical"

  const select = (index: number) => {
    setInnerStep(index)
    onStepChange?.(index)
  }

  return (
    <ol className={cn("flex", vertical ? "flex-col" : "w-full items-start", className)} {...props}>
      {steps.map((item, index) => {
        const state = index < current ? "complete" : index === current ? "current" : "upcoming"
        const interactive = clickable && state === "complete"
        const last = index === steps.length - 1
        return (
          <li
            key={item.title}
            aria-current={state === "current" ? "step" : undefined}
            className={cn("group relative flex", vertical ? "gap-4 pb-8 last:pb-0" : "flex-1 flex-col last:flex-none")}
          >
            {!last && (
              <span
                aria-hidden
                className={cn(
                  "absolute overflow-hidden bg-border",
                  vertical ? "top-10 bottom-2 left-[17px] w-0.5" : "top-[17px] right-3 left-12 h-0.5"
                )}
              >
                <span
                  className={cn(
                    "absolute inset-0 origin-left bg-primary transition-transform duration-500",
                    vertical && "origin-top",
                    state === "complete" ? "scale-100" : vertical ? "scale-y-0" : "scale-x-0"
                  )}
                />
              </span>
            )}
            <button
              type="button"
              disabled={!interactive}
              onClick={() => select(index)}
              aria-label={`Step ${index + 1}: ${item.title}${state === "complete" ? " (completed)" : ""}`}
              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 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none",
                state === "complete" && "border-primary bg-primary text-primary-foreground hover:opacity-90",
                state === "current" && "border-primary bg-background text-primary ring-4 ring-primary/15",
                state === "upcoming" && "border-border bg-background text-muted-foreground",
                !interactive && "cursor-default"
              )}
            >
              {state === "complete" ? <Check className="size-4 animate-in zoom-in-50" strokeWidth={3} /> : index + 1}
            </button>
            <div className={cn("flex flex-col gap-0.5", vertical ? "pt-1.5" : "mt-3 pr-4")}>
              <span className={cn("text-sm font-medium", state === "upcoming" && "text-muted-foreground")}>{item.title}</span>
              {item.description && <span className="text-xs text-muted-foreground">{item.description}</span>}
            </div>
          </li>
        )
      })}
    </ol>
  )
}
```
