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
- Step 1 of 4, done
- Step 2 of 4, current
- Step 3 of 4, not started
- 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
- Connect repositoryGitHub, GitLab or Bitbucket.Step 1 of 4, done
- Configure buildFramework detected: Next.js.Step 2 of 4, done
- Set environment variablesSecrets stay encrypted at rest.Step 3 of 4, current
- 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
- 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
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | { label: string; description?: string }[] | The steps, in order. | |
| current | number | 0 | Index 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) => void | Makes 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