Stepper
A flexible stepper and timeline component to visualize progress or workflows.
Usage
Horizontal Stepper
Step 1 of 4, completed.
Account
Create your account
Step 2 of 4, active.
Address
Enter your address
Step 3 of 4, inactive.
Payment
Add payment method
Step 4 of 4, inactive.
Confirm
Review and order
Vertical Stepper
Step 1 of 4, completed.
Account
Complete the account step to continue.
Step 2 of 4, active.
Address
Complete the address step to continue.
Step 3 of 4, inactive.
Payment
Complete the payment step to continue.
Step 4 of 4, inactive.
Confirm
Complete the confirm step to continue.
With Custom Icons
Step 1 of 4, completed.
Account
Step 2 of 4, active.
Address
Step 3 of 4, inactive.
Payment
Step 4 of 4, inactive.
Confirm
Loading & Error States
Step 1 of 4, completed.
Completed
Step 2 of 4, loading.
Loading
Step 3 of 4, error.
Error
Step 4 of 4, inactive.
Pending
Non-Clickable (Display Only)
Step 1 of 4, completed.
Account
Step 2 of 4, completed.
Address
Step 3 of 4, active.
Payment
Step 4 of 4, inactive.
Confirm
import { Stepper } from "vayu-ui";
export default function StepperExample() {
const [activeStep, setActiveStep] = useState(0);
return (
<Stepper.Root activeStep={activeStep} onStepClick={setActiveStep}>
<Stepper.Step>
<Stepper.Indicator>1</Stepper.Indicator>
<Stepper.Content>
<Stepper.Title>Step 1</Stepper.Title>
<Stepper.Description>Description</Stepper.Description>
</Stepper.Content>
</Stepper.Step>
<Stepper.Step>
<Stepper.Indicator>2</Stepper.Indicator>
<Stepper.Content>
<Stepper.Title>Step 2</Stepper.Title>
<Stepper.Description>Description</Stepper.Description>
</Stepper.Content>
</Stepper.Step>
</Stepper.Root>
);
}Features
- Horizontal & Vertical orientation
- Interactive steps (clickable)
- Automatic state management (active, completed, inactive)
- Custom icons and numbering
- Compound component pattern for maximum flexibility
- Fully accessible with ARIA support
Components
| Component | Description |
|---|---|
Stepper.Root | Root container. Manages active step and orientation. |
Stepper.Step | Individual step wrapper. Handles connecting lines. |
Stepper.Indicator | The circle/icon element. Styles change based on status. |
Stepper.Content | Container for text content (title and description). |
Stepper.Title | Title of the step. |
Stepper.Description | Helper text or detailed description. |
Props
Stepper.Root
| Prop | Type | Default | Description |
|---|---|---|---|
activeStep | number | 0 | The index of the currently active step |
orientation | "horizontal" | "vertical" | "horizontal" | Layout orientation |
onStepClick | (index: number) => void | — | Callback when a step is clicked |
className | string | — | Additional CSS classes |
Stepper.Step
| Prop | Type | Default | Description |
|---|---|---|---|
status | "active" | "completed" | "inactive" | "loading" | "error" | — | Explicitly override the step status |
className | string | — | Additional CSS classes |
Stepper.Indicator
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | Optional icon to replace the default content |
className | string | — | Additional CSS classes |
Stepper.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional CSS classes |
Stepper.Title
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional CSS classes |
Stepper.Description
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional CSS classes |
Status States
Steps automatically derive their status based on activeStep:
- inactive: Step index > activeStep
- active: Step index === activeStep
- completed: Step index < activeStep
You can override this by passing status prop directly to Stepper.Step.
Additional statuses:
- loading: Shows a pulsing animation
- error: Shows error styling with an exclamation mark
Accessibility
- Uses semantic
role="list"androle="listitem"for step structure aria-current="step"marks the active steparia-labelprovides step context for screen readers- Full keyboard navigation (Tab to focus, Enter/Space to select)
- Visible focus indicators for keyboard users
- Respects
prefers-reduced-motionfor animations
Source Code
'use client';
import { Check } from 'lucide-react';
import {
Children,
cloneElement,
createContext,
forwardRef,
HTMLAttributes,
isValidElement,
ReactNode,
useContext,
} from 'react';
import { clsx } from 'clsx';
// ============================================================================
// Types
// ============================================================================
type StepperOrientation = 'horizontal' | 'vertical';
type StepperStatus = 'active' | 'completed' | 'inactive' | 'loading' | 'error';
interface StepperContextValue {
activeStep: number;
orientation: StepperOrientation;
onStepClick?: (step: number) => void;
}
const StepperContext = createContext<StepperContextValue | undefined>(undefined);
// ============================================================================
// Root
// ============================================================================
interface StepperRootProps extends HTMLAttributes<HTMLDivElement> {
activeStep: number;
orientation?: StepperOrientation;
onStepClick?: (step: number) => void;
}
const StepperRoot = forwardRef<HTMLDivElement, StepperRootProps>(
({ activeStep, orientation = 'horizontal', onStepClick, className, children, ...props }, ref) => {
const elements = Children.toArray(children);
return (
<StepperContext.Provider value={{ activeStep, orientation, onStepClick }}>
<div
ref={ref}
role="list"
aria-label="Progress steps"
className={clsx(
'flex w-full gap-2',
orientation === 'vertical' ? 'flex-col' : 'flex-row',
className,
)}
{...props}
>
{elements.map((child, index) => {
if (!isValidElement(child)) return null;
return cloneElement(child, {
// @ts-ignore
index,
isLast: index === elements.length - 1,
});
})}
</div>
</StepperContext.Provider>
);
},
);
StepperRoot.displayName = 'Stepper.Root';
// ============================================================================
// Step
// ============================================================================
interface StepProps extends HTMLAttributes<HTMLDivElement> {
index?: number;
isLast?: boolean;
status?: StepperStatus;
}
const Step = forwardRef<HTMLDivElement, StepProps>(
(
{
index = 0,
isLast = false,
status: propStatus,
className,
children,
onClick,
onKeyDown,
...props
},
ref,
) => {
const { activeStep, orientation, onStepClick } = useContext(StepperContext)!;
// Derive status if not explicitly provided
let status: StepperStatus = propStatus || 'inactive';
if (!propStatus) {
if (activeStep > index) status = 'completed';
else if (activeStep === index) status = 'active';
else status = 'inactive';
}
const isClickable = !!onStepClick;
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (onStepClick) {
onStepClick(index);
}
onClick?.(e);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onStepClick?.(index);
}
onKeyDown?.(e);
};
return (
<div
ref={ref}
role="listitem"
className={clsx(
'relative flex',
orientation === 'vertical' ? 'flex-col' : 'items-center flex-1',
!isLast && orientation === 'horizontal' && 'flex-1',
isClickable && 'cursor-pointer',
className,
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={isClickable ? 0 : -1}
aria-current={status === 'active' ? 'step' : undefined}
aria-label={`Step {index + 1}{status === "active" ? ", current" : status === "completed" ? ", completed" : ""}`}
{...props}
>
{/* Horizontal Connector Line */}
{!isLast && orientation === 'horizontal' && (
<div
className={clsx(
'absolute top-5 left-[calc(50%+20px)] right-[calc(-50%+20px)] h-0.5 -translate-y-1/2 transition-colors duration-300',
status === 'completed' ? 'bg-primary-500' : 'bg-ground-200 dark:bg-ground-800',
)}
aria-hidden="true"
/>
)}
<div
className={clsx(
'flex',
orientation === 'vertical' ? 'flex-row gap-4' : 'flex-col items-center gap-2',
)}
>
{Children.map(children, (child) => {
if (!isValidElement(child)) return null;
return cloneElement(child, {
// @ts-ignore
status,
});
})}
</div>
{/* Vertical Connector Line */}
{!isLast && orientation === 'vertical' && (
<div
className={clsx(
'absolute left-5 top-10 bottom-0 w-0.5 -translate-x-1/2 my-2 transition-colors duration-300',
status === 'completed' ? 'bg-primary-500' : 'bg-ground-200 dark:bg-ground-800',
)}
aria-hidden="true"
/>
)}
</div>
);
},
);
Step.displayName = 'Stepper.Step';
// ============================================================================
// StepIndicator
// ============================================================================
interface StepIndicatorProps extends HTMLAttributes<HTMLDivElement> {
status?: StepperStatus;
icon?: ReactNode;
}
const StepIndicator = forwardRef<HTMLDivElement, StepIndicatorProps>(
({ status, icon, className, children, ...props }, ref) => {
return (
<div
ref={ref}
aria-hidden="true"
className={clsx(
'relative z-10 flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all duration-300 font-secondary font-semibold text-sm',
'group-focus-visible:ring-2 group-focus-visible:ring-primary-500 group-focus-visible:ring-offset-2',
status === 'active' &&
'border-primary-500 bg-primary-500 text-ground-950 ring-4 ring-primary-500/20',
status === 'completed' && 'border-primary-500 bg-primary-500 text-ground-950',
status === 'inactive' &&
'border-ground-200 dark:border-ground-700 bg-ground-50 dark:bg-ground-900 text-ground-500 dark:text-ground-400',
status === 'error' && 'border-error-500 bg-error-500 text-white',
status === 'loading' && 'border-primary-500 text-primary-500 animate-pulse',
className,
)}
{...props}
>
{status === 'completed' && !icon && !children ? (
<Check className="w-5 h-5" />
) : status === 'error' && !icon && !children ? (
<span className="text-lg font-bold">!</span>
) : (
children || icon
)}
</div>
);
},
);
StepIndicator.displayName = 'Stepper.Indicator';
// ============================================================================
// StepContent
// ============================================================================
interface StepContentProps extends HTMLAttributes<HTMLDivElement> {
status?: StepperStatus;
}
const StepContent = forwardRef<HTMLDivElement, StepContentProps>(
({ status, className, children, ...props }, ref) => {
const { orientation } = useContext(StepperContext)!;
return (
<div
ref={ref}
className={clsx(
'flex flex-col',
orientation === 'horizontal' && 'items-center text-center',
orientation === 'vertical' && 'pt-1 pb-6',
className,
)}
{...props}
>
{Children.map(children, (child) => {
if (!isValidElement(child)) return null;
return cloneElement(child, {
// @ts-ignore
status,
});
})}
</div>
);
},
);
StepContent.displayName = 'Stepper.Content';
// ============================================================================
// StepTitle
// ============================================================================
interface StepTitleProps extends HTMLAttributes<HTMLHeadingElement> {
status?: StepperStatus;
}
const StepTitle = forwardRef<HTMLHeadingElement, StepTitleProps>(
({ status, className, children, ...props }, ref) => {
return (
<h3
ref={ref}
className={clsx(
'font-secondary text-sm font-semibold transition-colors duration-200',
status === 'active'
? 'text-ground-900 dark:text-ground-100'
: 'text-ground-600 dark:text-ground-400',
status === 'completed' && 'text-ground-900 dark:text-ground-100',
className,
)}
{...props}
>
{children}
</h3>
);
},
);
StepTitle.displayName = 'Stepper.Title';
// ============================================================================
// StepDescription
// ============================================================================
interface StepDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
status?: StepperStatus;
}
const StepDescription = forwardRef<HTMLParagraphElement, StepDescriptionProps>(
({ status, className, children, ...props }, ref) => {
return (
<p
ref={ref}
className={clsx(
'font-secondary text-xs text-ground-500 dark:text-ground-400 max-w-[200px]',
className,
)}
{...props}
>
{children}
</p>
);
},
);
StepDescription.displayName = 'Stepper.Description';
// ============================================================================
// Compound Export
// ============================================================================
const Stepper = Object.assign(StepperRoot, {
Root: StepperRoot,
Step: Step,
Indicator: StepIndicator,
Content: StepContent,
Title: StepTitle,
Description: StepDescription,
});
export { Stepper, StepperRoot, Step, StepIndicator, StepContent, StepTitle, StepDescription };