Skip to content
goodtabs UI
BrowseBrowse components

Stepper

Numbered steps with hairline connectors, one current, the rest done or upcoming.

npx shadcn@latest add https://ui.goodtabs.co/r/stepper.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/stepper

  1. Step 1 of 4, done
  2. Step 2 of 4, current
  3. Step 3 of 4, not started
  4. Step 4 of 4, not started

Four steps in a row with the second one current. Labels hide when the row is narrow.

Vertical with descriptions

  1. Connect repositoryGitHub, GitLab or Bitbucket.Step 1 of 4, done
  2. Configure buildFramework detected: Next.js.Step 2 of 4, done
  3. Set environment variablesSecrets stay encrypted at rest.Step 3 of 4, current
  4. DeployFirst build takes about a minute.Step 4 of 4, not started

Stacked steps with one line under each label and a hairline down the left.

Clickable

  1. Step 3 of 3, current

Review, step 3 of 3

Pass onStepClick and done steps become buttons that jump back.

Usage

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

<Stepper
  current={1}
  steps={[{ label: "Account" }, { label: "Workspace" }, { label: "Done" }]}
/>

Props

PropTypeDefaultDescription
steps{ label: string; description?: string }[]The steps, in order.
currentnumber0Index of the current step. Everything before it is done.
orientation"horizontal" | "vertical""horizontal"A row with hairlines between, or a stack with a hairline down the left.
onStepClick(index: number) => voidMakes done steps buttons and reports the clicked index.

Source

stepper/stepper.tsx
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";

export type Step = {
  label: string;
  /** One line under the label. Always shown vertically, from 672px wide horizontally. */
  description?: string;
};

type StepperProps = Omit<React.ComponentProps<"ol">, "children"> & {
  /** The steps, in order. */
  steps: Step[];
  /** Index of the current step. Everything before it is done. */
  current?: number;
  /** Steps in a row with hairlines between, or stacked with a hairline down the left. */
  orientation?: "horizontal" | "vertical";
  /** Makes done steps buttons. Called with the step index. */
  onStepClick?: (index: number) => void;
};

/**
 * Numbered steps with one marked as current. Done steps get a check, upcoming ones stay grey.
 * Presentational by default: done steps become buttons only when onStepClick is passed.
 * Horizontal labels hide under 448px of container width so the circles always fit.
 */
export function Stepper({
  steps,
  current = 0,
  orientation = "horizontal",
  onStepClick,
  className,
  ...props
}: StepperProps) {
  const vertical = orientation === "vertical";
  return (
    <ol
      data-slot="stepper"
      data-orientation={orientation}
      className={cn("@container m-0 list-none p-0", vertical ? "flex flex-col" : "flex w-full items-center", className)}
      {...props}
    >
      {steps.map((step, i) => {
        const state = i < current ? "done" : i === current ? "current" : "upcoming";
        const last = i === steps.length - 1;
        const clickable = state === "done" && onStepClick;

        const content = (
          <>
            <span
              aria-hidden="true"
              className={cn(
                "inline-flex size-6 shrink-0 items-center justify-center rounded-full border font-mono text-xs transition-colors duration-100 ease-out",
                state === "done" && "border-primary bg-primary text-primary-foreground",
                state === "current" && "border-foreground text-foreground",
                state === "upcoming" && "text-muted-foreground",
              )}
            >
              {state === "done" ? <CheckIcon className="size-3.5" /> : i + 1}
            </span>
            <span className={cn("flex min-w-0 flex-col", !vertical && "hidden @md:flex")}>
              <span
                className={cn(
                  "text-sm leading-6 whitespace-nowrap",
                  state === "current" && "font-medium text-foreground",
                  state === "done" && "text-foreground",
                  state === "upcoming" && "text-muted-foreground",
                )}
              >
                {step.label}
              </span>
              {step.description && (
                <span className={cn("text-sm text-muted-foreground", !vertical && "hidden @2xl:block")}>
                  {step.description}
                </span>
              )}
            </span>
            <span className="sr-only">
              {`Step ${i + 1} of ${steps.length}, ${state === "done" ? "done" : state === "current" ? "current" : "not started"}`}
            </span>
          </>
        );

        const inner = clickable ? (
          <button
            type="button"
            onClick={() => onStepClick(i)}
            className="flex shrink-0 items-start gap-3 rounded-sm text-left transition-colors duration-100 ease-out outline-none focus-visible:ring-2 focus-visible:ring-ring/50 [&>span:first-child]:hover:bg-primary/85"
          >
            {content}
          </button>
        ) : (
          <span className="flex shrink-0 items-start gap-3">{content}</span>
        );

        return (
          <li
            key={i}
            data-state={state}
            aria-current={state === "current" ? "step" : undefined}
            className={cn(
              "flex",
              vertical ? "relative flex-col" : "min-w-0 items-center",
              vertical && !last && "pb-6",
              !vertical && !last && "flex-1",
            )}
          >
            {inner}
            {!last &&
              (vertical ? (
                <span className="absolute top-7 bottom-1 left-3 w-px bg-border" aria-hidden="true" />
              ) : (
                <span className="mx-3 h-px min-w-4 flex-1 bg-border" aria-hidden="true" />
              ))}
          </li>
        );
      })}
    </ol>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/stepper/prompt.md, or the Copy AI prompt button above.

When to use

  • Multi-step forms, onboarding and checkout where people need to know where they are
  • Vertical, with descriptions, as a setup checklist next to the form
  • When steps happen in order and earlier ones can be revisited

When not to use

  • For switching between unordered views, use segmented tabs
  • For a single loading or completion percentage, use a progress bar
  • For more than six steps: group them first

Agent notes

  • Server component by default; pass onStepClick from a client component and only done steps become buttons
  • Horizontal labels hide under a 448px container (@md) so four circles always fit; descriptions show from 672px
  • The li of the current step carries aria-current="step" and every step has a data-state of done, current or upcoming
  • Keep labels short: they never wrap horizontally, so long labels push the connectors to their 16px minimum