All componentsdisplay
Stepper
Compound API: Stepper, StepperItem, StepperIndicator, StepperContent.
Horizontal, numbered
- Completed step.
Account
Created
- Completed step.
Profile
Completed
- Current step.
Verification
In progress
Done
Not yet
Vertical
- Completed step.
Order placed
We received your order Monday.
- Current step.
Preparing shipment
Your order is being packed.
Out for delivery
Estimated Thursday.
With icons
- Completed step.
Cart
- Completed step.
Shipping
- Current step.
Payment
Delivered
Clickable navigation
Try Tab + Enter/Space: every step is a real button. Current: Shipping.
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/stepper.jsonnpm dependencies: lucide-react
Props
StepperProps
| Prop | Type | Required |
|---|---|---|
| orientation | StepperOrientation | No |
StepperItemProps
| Prop | Type | Required |
|---|---|---|
| onClick | () => void | No |
| disabled | boolean | No |
StepperIndicatorProps
| Prop | Type | Required |
|---|---|---|
| status | StepperStepStatus | No |
| icon | React.ReactNode | No |
StepperContentProps
| Prop | Type | Required |
|---|---|---|
| title | React.ReactNode | Yes |
| description | React.ReactNode | No |
| headingLevel | "h2" | "h3" | "h4" | "h5" | "h6" | No |
Code
stepper.tsx
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export type StepperOrientation = "horizontal" | "vertical";
export type StepperStepStatus = "upcoming" | "current" | "complete";
interface StepperContextValue {
orientation: StepperOrientation;
total: number;
}
const StepperContext = React.createContext<StepperContextValue | null>(null);
function useStepperContext(component: string) {
const context = React.useContext(StepperContext);
if (!context) {
throw new Error(`<${component}> must be used within <Stepper>.`);
}
return context;
}
const StepperItemIndexContext = React.createContext(0);
export interface StepperProps extends React.ComponentPropsWithoutRef<"ol"> {
orientation?: StepperOrientation;
}
/**
* Root list. Same index-via-context pattern as Timeline: derived purely
* from render order, no refs/effects, safe under StrictMode and identical
* on server vs. first client render.
*/
export function Stepper({ orientation = "horizontal", className, children, ...props }: StepperProps) {
const items = React.Children.toArray(children);
return (
<StepperContext.Provider value={{ orientation, total: items.length }}>
<ol
role="list"
aria-label="Progress"
data-orientation={orientation}
className={cn(
"relative flex list-none",
orientation === "horizontal" ? "w-full flex-row" : "flex-col",
className,
)}
{...props}
>
{items.map((child, index) => (
<StepperItemIndexContext.Provider key={index} value={index}>
{child}
</StepperItemIndexContext.Provider>
))}
</ol>
</StepperContext.Provider>
);
}
export interface StepperItemProps extends Omit<React.ComponentPropsWithoutRef<"li">, "onClick"> {
/** Present → step renders as a real <button>, keyboard-operable by default. Absent → non-interactive. */
onClick?: () => void;
disabled?: boolean;
}
/**
* A single step. Interactivity lives on this component (not on
* StepperIndicator) so the whole step — marker and label — is one click
* target and one native, natively-tabbable control, never a div with a
* click handler.
*/
export function StepperItem({ onClick, disabled, className, children, ...props }: StepperItemProps) {
const { orientation, total } = useStepperContext("StepperItem");
const index = React.useContext(StepperItemIndexContext);
const isLast = index === total - 1;
const childArray = React.Children.toArray(children);
const indicator = childArray.find(
(child): child is React.ReactElement<StepperIndicatorProps> =>
React.isValidElement(child) && child.type === StepperIndicator,
);
const content = childArray.filter((child) => child !== indicator);
const status: StepperStepStatus = indicator?.props.status ?? "upcoming";
const connectorClass = status === "complete" ? "bg-primary" : "bg-border";
const interactive = Boolean(onClick);
const body =
orientation === "horizontal" ? (
<>
<div className="relative z-10 flex items-center">
<span className="shrink-0">{indicator}</span>
{!isLast && (
<span
aria-hidden="true"
className={cn("h-[var(--stepper-connector-thickness)] flex-1", connectorClass)}
/>
)}
</div>
<div className="mt-[var(--stepper-gap)] flex flex-col gap-0.5">{content}</div>
</>
) : (
<>
{!isLast && (
<span
aria-hidden="true"
className={cn(
"absolute top-[var(--stepper-indicator-size)] bottom-0 left-[calc(var(--stepper-indicator-size)/2)] w-[var(--stepper-connector-thickness)] -translate-x-1/2",
connectorClass,
)}
/>
)}
<span className="relative z-10 shrink-0">{indicator}</span>
<div className="flex flex-col gap-0.5 pb-[var(--stepper-gap)]">{content}</div>
</>
);
const wrapperClassName = cn(
"text-left",
orientation === "horizontal" ? "flex flex-1 flex-col" : "flex gap-x-[var(--timeline-gutter)]",
);
return (
<li
className={cn(
"relative",
orientation === "horizontal" ? "flex flex-1 flex-col" : "flex",
className,
)}
{...props}
>
{interactive ? (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-current={status === "current" ? "step" : undefined}
className={cn(
wrapperClassName,
"cursor-pointer rounded-md outline-none transition-opacity",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
disabled && "cursor-not-allowed opacity-50",
)}
>
{body}
</button>
) : (
<div aria-current={status === "current" ? "step" : undefined} className={wrapperClassName}>
{body}
</div>
)}
</li>
);
}
export interface StepperIndicatorProps extends React.ComponentPropsWithoutRef<"span"> {
status?: StepperStepStatus;
icon?: React.ReactNode;
}
const stepStatusLabel: Record<StepperStepStatus, string | null> = {
upcoming: null,
current: "Current step. ",
complete: "Completed step. ",
};
/**
* The numbered/icon marker. Decorative (aria-hidden) — status is announced
* via a sibling sr-only span, same reasoning as Timeline's node: text
* nested inside an aria-hidden element never reaches assistive tech.
* Falls back to its own 1-based step number when no icon is given, and to
* a check mark when complete — matches Timeline's shape-over-color-alone
* approach (empty ring / ring+number / filled+check).
*/
export function StepperIndicator({ status = "upcoming", icon, className, children, ...props }: StepperIndicatorProps) {
const index = React.useContext(StepperItemIndexContext);
const label = stepStatusLabel[status];
return (
<>
{label ? <span className="sr-only">{label}</span> : null}
<span
aria-hidden="true"
className={cn(
"relative flex size-[var(--stepper-indicator-size)] shrink-0 items-center justify-center rounded-full border-[length:var(--stepper-indicator-border-width)] bg-background text-sm font-medium text-muted-foreground",
status === "upcoming" && "border-border",
status === "current" && "border-primary text-primary",
status === "complete" && "border-primary bg-primary text-primary-foreground",
className,
)}
{...props}
>
{status === "current" && (
<span className="absolute inset-0 -z-10 rounded-full bg-primary/40 motion-safe:animate-ping" />
)}
{icon ??
children ??
(status === "complete" ? (
<Check className="size-[calc(var(--stepper-indicator-size)*0.5)]" />
) : (
index + 1
))}
</span>
</>
);
}
export interface StepperContentProps extends Omit<React.ComponentPropsWithoutRef<"div">, "title"> {
title: React.ReactNode;
description?: React.ReactNode;
/** Heading level for `title` — pick what fits the page's own hierarchy. */
headingLevel?: "h2" | "h3" | "h4" | "h5" | "h6";
}
export function StepperContent({
title,
description,
headingLevel: Heading = "h3",
className,
children,
...props
}: StepperContentProps) {
return (
<div className={cn("flex flex-col gap-0.5", className)} {...props}>
<Heading className="text-sm font-semibold leading-snug text-foreground">{title}</Heading>
{description ? (
<p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
) : null}
{children}
</div>
);
}