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 (a green ring and check), the current step is ringed in the accent, later steps stay muted. Steps are numbered automatically by position.
- Completed: Cart
- Current step: Shipping
- Upcoming step: Payment
- Upcoming step: Review
Wrapping labels
Horizontal labels get a fixed measure and wrap inside it, so a multi-word title keeps the dots evenly spaced instead of widening its own step. The measure does not grow with the container: sentence-length copy belongs in the vertical orientation.
- Completed: Connect your repoGitHub, GitLab, or Bitbucket.
- Completed: We detect and buildFramework inferred from the source.
- Current step: Every push deploysPreviews on branches.
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 { IconCheck as Check } from "@tabler/icons-react"
import { cn } from "@/registry/lib/utils"
type Orientation = "horizontal" | "vertical"
type StepState = "completed" | "active" | "upcoming"
type StepperSize = "default" | "sm"
const StepperContext = React.createContext<{
value: number
orientation: Orientation
count: number
size: StepperSize
} | 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
}
// @use-when a multi-step flow's progress: which steps are done, which is
// active, which are still ahead.
function Stepper({
value,
orientation = "horizontal",
size = "default",
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
/** One rung down for dense rails: a 24px indicator with a 13px title,
* connectors, check and reserves scaled with it. Every part reads it from
* context, so the two sizes stay one component. */
size?: StepperSize
}) {
// Every element child is a step, in order, so we can auto-number them and
// interleave connectors without the consumer wiring either by hand.
//
// Deliberately NOT `child.type === StepperItem`. This module is
// `"use client"`, so a Server Component importing StepperItem holds a client
// REFERENCE, not this function, and the identity test fails for every child:
// the stepper then renders as an empty <ol> on any App Router page, which is
// a Server Component by default. Nothing catches it (tsc, the build, and the
// registry checks all pass, and it works in a client page), so the numbering
// is positional instead and never inspects a child's type.
const items = React.Children.toArray(children).filter(
(child): child is React.ReactElement<StepperItemProps> =>
React.isValidElement(child)
)
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, size }}>
<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, size } = 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}
{/* Fill reads the Stepper's `value`, never this item's `state`, the
same rule the horizontal rail uses. A `state` override changes
what the CIRCLE says (a done step being re-read as active); the
line below it is behind the flow either way. Keyed on state, an
override to "active" on step 1 of a flow standing at step 4 greyed
the first segment while the next two stayed filled, which read
as the flow having skipped a step. */}
{!isLast && (
<StepperConnector orientation="vertical" filled={step < value} />
)}
</div>
{hasContent && (
<div className={cn("flex flex-col", !isLast && (size === "sm" ? "pb-6" : "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={cn(
"flex flex-col justify-center gap-0.5",
size === "sm" ? "min-h-6" : "min-h-8"
)}
>
{content}
</div>
</div>
)}
</li>
</StepperItemContext.Provider>
)
}
// Horizontal: an intrinsic-width column (the connectors live between items at
// the Stepper level). The label sits in a fixed-measure 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 the measure below 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 flex-col gap-0.5 sm:flex",
// The label gets a real measure and gives back exactly as much
// margin as it takes in width, so it wraps on a line of its own
// choosing while contributing NOTHING to the row: the steps stay
// sized by their indicators and the flex-1 connectors keep the
// dots evenly spaced, first flush left and last flush right.
//
// It used to be `w-0 whitespace-nowrap`, which centred the label
// the same way but could only ever hold one short line. Two
// things were wrong with that. A multi-word title wrapped once
// per SPACE, because `whitespace-nowrap` and the `text-pretty` on
// StepperTitle are both shorthands over the same longhand,
// `text-wrap-mode`, and a declaration on the title beat the value
// it inherited from this box. And even with that settled, a label
// longer than the gap between two dots just ran over its
// neighbour, silently, only for the consumers whose words were
// long. A measure fixes both: it is the one number that says how
// much room a label has.
//
// The measure is fixed rather than derived, so it does not track
// step count: a horizontal stepper with many steps in a narrow
// container will still collide, and long flows belong in the
// vertical orientation.
size === "sm" ? "w-28 -mx-14" : "w-32 -mx-16",
isFirst
? size === "sm"
? "self-start items-start text-start mx-0 -me-28"
: "self-start items-start text-start mx-0 -me-32"
: isLast
? size === "sm"
? "self-end items-end text-end mx-0 -ms-28"
: "self-end items-end text-end mx-0 -ms-32"
: "items-center text-center"
)}
>
{content}
</div>
)}
</li>
</StepperItemContext.Provider>
)
}
function StepperConnector({
orientation,
filled,
}: {
orientation: Orientation
filled: boolean
}) {
const { size } = useStepper()
const isHorizontal = orientation === "horizontal"
// The 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.
//
// One colour end to end, and the same one the completed indicators use. A
// filled connector means the segment is BEHIND you, and everything behind you
// is done, so it is success for its whole length. The connector that lands on
// the active step used to blend to the accent, which said the segment itself
// was in progress; it is not, the step is. Losing the blend also loses the
// question it raised, which was why one line in the rail was two colours.
// The junction it was smoothing needs no smoothing: the active indicator
// carries a 4px accent halo outside its border, so the line ends underneath
// that halo rather than against the disc.
const fill = (
<span
className={cn(
// `ease-in-out`, not `ease-out`: the fill is already on screen and
// travels, so it needs to accelerate as well as settle. `ease-out`
// belongs to things entering or leaving.
// Exactly the rung the completed indicator's ring and check use, so
// circle and line are one rail with no seam. Retune the three together
// or not at all.
"absolute inset-0 rounded-full bg-success-500 transition-transform duration-300 ease-in-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={cn("flex min-w-4 flex-1 items-center", size === "sm" ? "h-6" : "h-8")}
>
<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 { size } = useStepper()
const { step, state } = useStepperItem()
const showCheck = state === "completed"
return (
<span
data-slot="stepper-indicator"
data-state={state}
className={cn(
"relative flex shrink-0 items-center justify-center rounded-full border font-medium tabular-nums",
// sm: a 24px circle wants a 12px numeral and half the active ring.
size === "sm" ? "size-6 text-1xs" : "size-8 text-sm",
// No reduced-motion guard: every property here is colour or shadow, so
// nothing moves and the state change stays legible at any preference.
"transition-[color,background-color,border-color,box-shadow] duration-200 ease-out",
"border-border bg-background text-muted-foreground",
// The active halo doubles its alpha in dark: 15% accent is a clear halo
// on white and invisible on near-black.
"data-[state=active]:border-accent data-[state=active]:bg-accent data-[state=active]:text-accent-foreground data-[state=active]:ring-accent-500/15 dark:data-[state=active]:ring-accent-500/30",
size === "sm" ? "data-[state=active]:ring-2" : "data-[state=active]:ring-4",
// Completed stays quiet (no fill, Ant's finished-step pattern): a long
// run of done steps should recede so the active step carries the
// weight, and ten solid success-500 discs made "done" the loudest thing
// on screen. The base bg-background stands, so the ring and the glyph
// carry "done" on their own and a green tint never goes misty on a
// non-white canvas. The edge matches the connector's WIDTH too (2px,
// the bar's 0.5) so circle and line read as one rail.
//
// Ring, check and connector are ONE rung, success-500, in both themes.
// The ring used to be a 300 around a 500 check, which is two greens in
// one 32px circle for no reason a reader could name, and the 300 was
// also the weakest thing here: 1.66:1 against the light background,
// against the 3:1 WCAG 1.4.11 asks of a graphic that carries meaning.
//
// The 500 measures 2.85:1 on the light page and 6.60:1 on the dark one,
// so light sits just under that 3:1 line and this is a KNOWN, chosen
// trade: the 600 clears it at 4.22:1 and was tried and rejected as a
// step too dark for a state that is meant to recede. What keeps it
// defensible is that the colour is not carrying the state on its own.
// Completed swaps the numeral for a CHECK, which is a shape change, and
// the indicator's sr-only text says "Completed:" outright, so nothing
// here is legible only to someone who can resolve green from grey.
// If the light rail ever needs to clear 3:1 on its own, move all three
// to the 600 together; they are one rail and they retune as one.
"data-[state=completed]:border-2 data-[state=completed]:border-success-500 data-[state=completed]:text-success-500",
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-[opacity]",
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-[opacity]",
showCheck ? "scale-100 opacity-100" : "scale-95 opacity-0"
)}
>
{/* This check is rail furniture, not a label glyph, so its stroke is
pinned to the ring it sits inside (border-2) and the connector
(w-0.5), both 2px. It deliberately does NOT read --stroke-icon:
that token is matched to a font weight, so a consumer retuning it
for labels would drift this check off the ring beside it.
A Tabler glyph paints strokeWidth * box / 24 px, so a nominal 2
painted only 1.33px here and 1.0px at sm, half the ring. The calc
inverts that formula to land exactly 2px, and both sizes fall on
whole device pixels at 2x. Change the box, keep the calc.
The box stays at the original 16/12: with the stroke corrected the
glyph carries 50% more ink than it did, so it reads bigger without
growing. A 20px box was tried and was far too large. */}
<Check
className={cn(
size === "sm"
? "size-3 [stroke-width:calc(2*24/12)]"
: "size-4 [stroke-width:calc(2*24/16)]"
)}
/>
</span>
</span>
)
}
function StepperTitle({ className, ...props }: React.ComponentProps<"div">) {
const { size } = useStepper()
const { state } = useStepperItem()
return (
<div
data-slot="stepper-title"
data-state={state}
className={cn(
// text-pretty, not text-balance: this title sits left-aligned above a
// description of the same width, and balance would end the two blocks
// at different right edges. See dialog.tsx.
//
// This wrap style is also a wrap MODE, so it only works where the box
// around it lets the title wrap. Never put `whitespace-nowrap` on that
// box expecting it to win: it loses to this line, and the horizontal
// label box in StepperItem carries the story of what that cost.
"font-medium leading-tight text-pretty",
size === "sm" ? "text-xs" : "text-sm",
state === "upcoming" ? "text-muted-foreground" : "text-foreground",
className
)}
{...props}
/>
)
}
function StepperDescription({ className, ...props }: React.ComponentProps<"div">) {
const { size } = useStepper()
return (
<div
data-slot="stepper-description"
className={cn(
"leading-snug text-muted-foreground text-pretty",
size === "sm" ? "text-2xs" : "text-xs",
className
)}
{...props}
/>
)
}
export {
Stepper,
StepperItem,
StepperIndicator,
StepperTitle,
StepperDescription,
}