Stepper
A multi-step progress indicator that shows completed, current, and upcoming steps.
Installation
$
Usage
import {
Stepper,
StepperItem,
StepperIndicator,
StepperTitle,
StepperDescription,
} from "@/components/ui/stepper"Examples
Default
value is the step in progress: earlier steps complete (filled check), the current step is ringed, later steps stay muted. Steps are numbered automatically by position.
- Completed: Cart
- Current step: Shipping
- Upcoming step: Payment
- Upcoming step: Review
Vertical
Set orientation="vertical" for a left rail that runs alongside each step. This layout fits step descriptions.
- Completed:Create accountYour email and a password.
- Current step:Verify emailConfirm the link we sent you.
- Upcoming step:Build your profileAdd a name and an avatar.
Interactive
Drive value from state to advance the flow. Pass a value past the last step to mark every step complete.
- Current step: Account
- Upcoming step: Profile
- Upcoming step: Billing
- Upcoming step: Done
Step 1 of 4
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| valueStepper | number | - | The 1-based step currently in progress. Steps before it read as completed, steps after it as upcoming. Pass a number past the last step to mark the flow finished. |
| orientationStepper | "horizontal" | "vertical" | "horizontal" | Layout axis. Horizontal places labels beneath evenly-spaced indicators; vertical runs a left rail beside each step and suits descriptions. |
| childrenStepperItem, StepperIndicator | ReactNode | - | On <StepperItem />, the first child is treated as the <StepperIndicator /> and everything after it is label content (<StepperTitle />, <StepperDescription />). On <StepperIndicator />, optional custom content for incomplete steps (defaults to the step number; completed steps always show a check). |
"use client"
import * as React from "react"
import { Check } from "lucide-react"
import { cn } from "@/registry/lib/utils"
type Orientation = "horizontal" | "vertical"
type StepState = "completed" | "active" | "upcoming"
const StepperContext = React.createContext<{
value: number
orientation: Orientation
count: number
} | null>(null)
function useStepper() {
const ctx = React.useContext(StepperContext)
if (!ctx) throw new Error("Stepper parts must be used within <Stepper>")
return ctx
}
const StepperItemContext = React.createContext<{
step: number
state: StepState
} | null>(null)
function useStepperItem() {
const ctx = React.useContext(StepperItemContext)
if (!ctx) throw new Error("Stepper item parts must be used within <StepperItem>")
return ctx
}
function Stepper({
value,
orientation = "horizontal",
className,
children,
...props
}: Omit<React.ComponentProps<"ol">, "value"> & {
/** The 1-based step currently in progress. Earlier steps read as completed, later steps as upcoming. Pass a number past the last step to mark the flow finished. */
value: number
orientation?: Orientation
}) {
// Keep only StepperItem children, in order, so we can auto-number them and
// interleave connectors without the consumer wiring either by hand.
const items = React.Children.toArray(children).filter(
(child): child is React.ReactElement<StepperItemProps> =>
React.isValidElement(child) && child.type === StepperItem
)
const count = items.length
// Horizontal: intrinsic-width steps separated by equal `flex-1` connectors,
// so the steps distribute evenly with the first flush left and the last flush
// right. Vertical steps own their own connector (a left rail).
const rendered =
orientation === "horizontal"
? items.flatMap((child, i) => {
const step = i + 1
const node = React.cloneElement(child, { step, key: `step-${step}` })
if (step === count) return [node]
return [
node,
<StepperConnector
key={`connector-${step}`}
orientation="horizontal"
filled={step < value}
/>,
]
})
: items.map((child, i) =>
React.cloneElement(child, { step: i + 1, key: `step-${i + 1}` })
)
return (
<StepperContext.Provider value={{ value, orientation, count }}>
<ol
data-slot="stepper"
data-orientation={orientation}
className={cn(
"flex",
orientation === "horizontal" ? "w-full flex-row items-start" : "flex-col",
className
)}
{...props}
>
{rendered}
</ol>
</StepperContext.Provider>
)
}
interface StepperItemProps extends React.ComponentProps<"li"> {
/** Injected automatically by `<Stepper>` from item position; rarely set by hand. */
step?: number
/** Override the state derived from the Stepper's `value`. Use when completion is
* not strictly linear — e.g. the step being edited must read as active even
* though its own fields are already complete, rather than flipping to a
* checkmark while it is still on screen. */
state?: StepState
}
function StepperItem({ step = 0, state: stateProp, className, children, ...props }: StepperItemProps) {
const { value, orientation, count } = useStepper()
const state: StepState =
stateProp ?? (step < value ? "completed" : step === value ? "active" : "upcoming")
const isFirst = step === 1
const isLast = step === count
// First child is the indicator; everything after is label content.
const [indicator, ...content] = React.Children.toArray(children)
const hasContent = content.length > 0
if (orientation === "vertical") {
return (
<StepperItemContext.Provider value={{ step, state }}>
<li
data-slot="stepper-item"
data-state={state}
data-orientation="vertical"
aria-current={state === "active" ? "step" : undefined}
className={cn("group/step flex flex-row gap-3", className)}
{...props}
>
{/* Left rail: indicator with the connector running down to the next step. */}
<div className="flex flex-col items-center self-stretch">
{indicator}
{!isLast && (
<StepperConnector orientation="vertical" filled={state === "completed"} />
)}
</div>
{hasContent && (
<div className={cn("flex flex-col", !isLast && "pb-8")}>
{/* Reserve the indicator's height and center within it, so a
title on its own sits level with the circle. Title plus
description already exceeds that height, so it stays put. */}
<div className="flex min-h-8 flex-col justify-center gap-0.5">
{content}
</div>
</div>
)}
</li>
</StepperItemContext.Provider>
)
}
// Horizontal: an intrinsic-width column (the connectors live between items at
// the Stepper level). Labels sit in a zero-width box centered under the
// indicator, anchored inward at the ends so the first/last never clip.
return (
<StepperItemContext.Provider value={{ step, state }}>
<li
data-slot="stepper-item"
data-state={state}
data-orientation="horizontal"
aria-current={state === "active" ? "step" : undefined}
className={cn("group/step flex shrink-0 flex-col items-center gap-2", className)}
{...props}
>
{indicator}
{hasContent && (
<div
className={cn(
// Labels are hidden on phones, where a zero-width nowrap label
// would collide with its neighbour and spill past the viewport;
// the numbered indicator rail carries the progress on its own.
// They return from sm up, where there is room to lay them out.
"hidden w-0 flex-col gap-0.5 whitespace-nowrap sm:flex",
isFirst
? "self-start items-start text-left"
: isLast
? "self-end items-end text-right"
: "items-center text-center"
)}
>
{content}
</div>
)}
</li>
</StepperItemContext.Provider>
)
}
function StepperConnector({
orientation,
filled,
}: {
orientation: Orientation
filled: boolean
}) {
const isHorizontal = orientation === "horizontal"
// The accent fill wipes in from the side nearest the completed step using a
// transform (GPU-friendly), so progress reads as advancing forward and
// retracting on the way back.
const fill = (
<span
className={cn(
"absolute inset-0 rounded-full bg-accent transition-transform duration-300 ease-out motion-reduce:transition-none",
isHorizontal ? "origin-left" : "origin-top",
filled
? isHorizontal
? "scale-x-100"
: "scale-y-100"
: isHorizontal
? "scale-x-0"
: "scale-y-0"
)}
/>
)
if (!isHorizontal) {
return (
<span
data-slot="stepper-connector"
aria-hidden
className="relative w-0.5 min-h-6 flex-1 overflow-hidden rounded-full bg-border"
>
{fill}
</span>
)
}
// Wrapper matches the indicator height so the line centers on the indicator,
// independent of the label stacked below it.
return (
<span
data-slot="stepper-connector"
aria-hidden
className="flex h-8 min-w-4 flex-1 items-center"
>
<span className="relative h-0.5 w-full overflow-hidden rounded-full bg-border">
{fill}
</span>
</span>
)
}
function StepperIndicator({
className,
children,
...props
}: React.ComponentProps<"span">) {
const { step, state } = useStepperItem()
const showCheck = state === "completed"
return (
<span
data-slot="stepper-indicator"
data-state={state}
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-medium tabular-nums",
"transition-[color,background-color,border-color,box-shadow] duration-200 ease-out motion-reduce:transition-none",
"border-border bg-background text-muted-foreground",
"data-[state=active]:border-accent data-[state=active]:bg-accent data-[state=active]:text-accent-foreground data-[state=active]:ring-4 data-[state=active]:ring-accent-500/15",
"data-[state=completed]:border-accent data-[state=completed]:bg-accent data-[state=completed]:text-accent-foreground",
className
)}
{...props}
>
<span className="sr-only">
{showCheck
? "Completed: "
: state === "active"
? "Current step: "
: "Upcoming step: "}
</span>
{/* Number (or custom content) and the check cross-fade in place, so the
swap never shifts layout moving forward or backward. */}
<span
aria-hidden
className={cn(
"absolute inset-0 flex items-center justify-center transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none",
showCheck ? "scale-95 opacity-0" : "scale-100 opacity-100"
)}
>
{children ?? step}
</span>
<span
aria-hidden
className={cn(
"absolute inset-0 flex items-center justify-center transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none",
showCheck ? "scale-100 opacity-100" : "scale-95 opacity-0"
)}
>
<Check className="size-4" />
</span>
</span>
)
}
function StepperTitle({ className, ...props }: React.ComponentProps<"div">) {
const { state } = useStepperItem()
return (
<div
data-slot="stepper-title"
data-state={state}
className={cn(
"text-sm font-medium leading-tight",
state === "upcoming" ? "text-muted-foreground" : "text-foreground",
className
)}
{...props}
/>
)
}
function StepperDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="stepper-description"
className={cn("text-xs leading-snug text-muted-foreground", className)}
{...props}
/>
)
}
export {
Stepper,
StepperItem,
StepperIndicator,
StepperTitle,
StepperDescription,
}