Onboarding Stepper
A controlled multi-step wizard with a step indicator, per-step content panels, validation errors, and a navigation footer.
Installation
npx cano-ui add onboarding-stepper
Installs button, progress, lucide-react automatically if missing.
Source
"use client"
import * as React from "react"
import { Check, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
export interface StepperStep {
id: string
title: string
description?: string
}
interface StepperContextValue {
steps: StepperStep[]
currentStep: number
onStepChange: (step: number) => void
nonLinear: boolean
maxStepReached: number
}
const StepperContext = React.createContext<StepperContextValue | null>(null)
function useStepper(): StepperContextValue {
const context = React.useContext(StepperContext)
if (!context) {
throw new Error("Stepper components must be used within <Stepper>.")
}
return context
}
export interface StepperProps extends React.HTMLAttributes<HTMLDivElement> {
steps: StepperStep[]
/** Zero-based index of the active step. */
currentStep: number
onStepChange: (step: number) => void
/** Allow jumping to any previously visited step via the indicator. */
nonLinear?: boolean
/** Replace the step circles with a progress bar and a "Step x of y" counter. */
compact?: boolean
}
function Stepper({
steps,
currentStep,
onStepChange,
nonLinear = false,
compact = false,
className,
children,
...props
}: StepperProps) {
const [maxStepReached, setMaxStepReached] = React.useState(currentStep)
React.useEffect(() => {
setMaxStepReached((max) => Math.max(max, currentStep))
}, [currentStep])
const value = React.useMemo<StepperContextValue>(
() => ({ steps, currentStep, onStepChange, nonLinear, maxStepReached }),
[steps, currentStep, onStepChange, nonLinear, maxStepReached]
)
return (
<StepperContext.Provider value={value}>
<div className={cn("flex flex-col gap-8", className)} {...props}>
{compact ? <StepperCompactIndicator /> : <StepperIndicator />}
{children}
</div>
</StepperContext.Provider>
)
}
function StepperCompactIndicator({ className }: { className?: string }) {
const { steps, currentStep } = useStepper()
const active = steps[currentStep]
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex items-baseline justify-between gap-4">
<p className="truncate text-sm font-medium text-foreground">
{active?.title}
</p>
<p className="shrink-0 text-sm text-muted-foreground">
Step {currentStep + 1} of {steps.length}
</p>
</div>
<Progress
value={((currentStep + 1) / steps.length) * 100}
aria-label={`Step ${currentStep + 1} of ${steps.length}`}
/>
</div>
)
}
function StepperIndicator({ className }: { className?: string }) {
const { steps, currentStep, onStepChange, nonLinear, maxStepReached } =
useStepper()
function canNavigateTo(index: number): boolean {
if (index === currentStep) return false
return nonLinear ? index <= maxStepReached : index < currentStep
}
return (
<div className={cn("flex flex-col gap-3", className)}>
<ol className="flex items-center gap-3">
{steps.map((step, index) => {
const status =
index < currentStep
? "completed"
: index === currentStep
? "active"
: "upcoming"
const clickable = canNavigateTo(index)
const isLast = index === steps.length - 1
return (
<li
key={step.id}
aria-current={status === "active" ? "step" : undefined}
className={cn("flex items-center gap-3", !isLast && "flex-1")}
>
<button
type="button"
disabled={!clickable}
onClick={() => onStepChange(index)}
aria-label={`Step ${index + 1}: ${step.title}`}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
status === "completed" &&
"border-primary bg-primary text-primary-foreground",
status === "active" &&
"border-primary text-foreground ring-2 ring-ring/20",
status === "upcoming" && "text-muted-foreground",
!clickable && "cursor-default"
)}
>
{status === "completed" ? (
<Check className="size-4" aria-hidden="true" />
) : (
index + 1
)}
</button>
<div className="hidden min-w-0 md:block">
<p className="truncate text-sm font-medium leading-none text-foreground">
{step.title}
</p>
{step.description ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{step.description}
</p>
) : null}
</div>
{!isLast ? (
<div
aria-hidden="true"
className={cn(
"h-px flex-1 bg-border transition-colors",
index < currentStep && "bg-primary"
)}
/>
) : null}
</li>
)
})}
</ol>
<p className="text-sm text-muted-foreground md:hidden">
Step {currentStep + 1} of {steps.length}:{" "}
<span className="font-medium text-foreground">
{steps[currentStep]?.title}
</span>
</p>
</div>
)
}
export interface StepperContentProps
extends React.HTMLAttributes<HTMLDivElement> {
/** Zero-based index of the step this panel belongs to. */
step: number
}
function StepperContent({
step,
className,
children,
...props
}: StepperContentProps) {
const { currentStep } = useStepper()
if (step !== currentStep) return null
return (
<div className={cn("flex flex-col gap-4", className)} {...props}>
{children}
</div>
)
}
export interface StepperFooterProps {
onBack?: () => void
onNext?: () => void
isSubmitting?: boolean
/** Defaults to "Next", or "Submit" on the last step. */
nextLabel?: string
backLabel?: string
/** Validation error for the current step, announced politely. */
error?: string
className?: string
}
function StepperFooter({
onBack,
onNext,
isSubmitting = false,
nextLabel,
backLabel = "Back",
error,
className,
}: StepperFooterProps) {
const { steps, currentStep } = useStepper()
const isFirst = currentStep === 0
const isLast = currentStep === steps.length - 1
return (
<div className={cn("flex flex-col gap-3", className)}>
<p
aria-live="polite"
className={cn("text-sm text-destructive", !error && "sr-only")}
>
{error}
</p>
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={onBack}
disabled={isFirst || isSubmitting}
>
{backLabel}
</Button>
<Button type="button" onClick={onNext} disabled={isSubmitting}>
{isSubmitting ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : null}
{nextLabel ?? (isLast ? "Submit" : "Next")}
</Button>
</div>
</div>
)
}
export { Stepper, StepperContent, StepperFooter, useStepper }
This exact file lands in your project at components/ui/onboarding-stepper.tsx. It is yours to edit.