Animated Stepper
A smooth, animated stepper for multi-step flows with token-aware visuals and spring transitions.
Installation
Add this component to your project using the CLI:
npx vui-registry-cli-v1 add stepperSource Code
'use client'
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Check, ChevronRight, User, Settings, CreditCard, Flag, Sparkles, ShieldCheck, Mail, Globe, Lock } from 'lucide-react'
import { cn } from '@/lib/utils'
import confetti from 'canvas-confetti'
interface Step {
id: string
title: string
description: string
icon: React.ComponentType<any>
}
const STEPS: Step[] = [
{ id: '1', title: 'Profile', description: 'Personal info', icon: User },
{ id: '2', title: 'Security', description: 'Account safety', icon: ShieldCheck },
{ id: '3', title: 'Billing', description: 'Payment method', icon: CreditCard },
{ id: '4', title: 'Finish', description: 'Complete setup', icon: Sparkles },
]
export interface StepperProps {
steps?: Step[]
initialStep?: number
onStepChange?: (step: number) => void
onComplete?: () => void
className?: string
}
export function Stepper({ steps = STEPS, initialStep = 1, onStepChange, onComplete, className }: StepperProps) {
const [currentStep, setCurrentStep] = useState(Math.min(Math.max(initialStep, 1), steps.length))
const [direction, setDirection] = useState(0)
const [isCompleted, setIsCompleted] = useState(false)
const [showToast, setShowToast] = useState(false)
const handleNext = () => {
if (currentStep < steps.length) {
setDirection(1)
setCurrentStep((prev) => {
const next = prev + 1
onStepChange?.(next)
return next
})
} else {
handleComplete()
}
}
const handlePrev = () => {
if (currentStep > 1) {
setDirection(-1)
setCurrentStep((prev) => {
const next = prev - 1
onStepChange?.(next)
return next
})
}
}
const handleComplete = () => {
setIsCompleted(true)
onComplete?.()
// Trigger confetti
const duration = 3 * 1000
const animationEnd = Date.now() + duration
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min
const interval: any = setInterval(function() {
const timeLeft = animationEnd - Date.now()
if (timeLeft <= 0) {
return clearInterval(interval)
}
const particleCount = 50 * (timeLeft / duration)
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } })
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } })
}, 250)
// Show toast
setShowToast(true)
setTimeout(() => setShowToast(false), 3000)
}
return (
<div className={cn("w-full max-w-4xl mx-auto p-6 md:p-12 font-sans bg-white dark:bg-zinc-950 rounded-3xl shadow-xl border border-zinc-100 dark:border-zinc-900", className)}>
{/* Toast Notification */}
<AnimatePresence>
{showToast && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.9 }}
className="fixed top-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-6 py-3 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-full shadow-2xl"
>
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" strokeWidth={3} />
</div>
<span className="font-semibold text-sm">Setup Completed Successfully!</span>
</motion.div>
)}
</AnimatePresence>
{/* Stepper Header */}
<div className="relative flex items-center justify-between mb-16 px-2">
<div className="absolute top-1/2 left-0 w-full h-1 bg-zinc-100 dark:bg-zinc-800 -z-10 rounded-full" />
{steps.map((step, index) => {
const stepNum = index + 1
const isCompletedStep = stepNum < currentStep || isCompleted
const isActive = stepNum === currentStep && !isCompleted
return (
<div key={step.id} className="relative flex flex-col items-center group">
<motion.div
className={cn(
'w-12 h-12 rounded-2xl flex items-center justify-center border-2 transition-all duration-500 z-10',
isActive
? 'border-indigo-600 bg-white dark:bg-zinc-900 text-indigo-600 shadow-[0_0_0_4px_rgba(79,70,229,0.1)] scale-110'
: isCompletedStep
? 'border-indigo-600 bg-indigo-600 text-white'
: 'border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-400'
)}
initial={false}
animate={{
scale: isActive ? 1.1 : 1,
}}
>
{isCompletedStep ? (
<Check className="w-6 h-6" strokeWidth={3} />
) : (
<step.icon className="w-5 h-5" />
)}
</motion.div>
<div className="absolute top-16 flex flex-col items-center w-32 text-center">
<span className={cn(
"text-sm font-bold transition-colors duration-300",
isActive || isCompletedStep ? "text-indigo-600 dark:text-indigo-400" : "text-zinc-500"
)}>
{step.title}
</span>
<span className="text-[10px] uppercase tracking-wider font-semibold text-zinc-400 mt-1">
{step.description}
</span>
</div>
{/* Connecting Line Progress */}
{index < STEPS.length - 1 && (
<div className="absolute top-1/2 left-1/2 w-full h-1 -z-20 pointer-events-none">
<motion.div
className="h-full bg-indigo-600 origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: isCompletedStep ? 1 : 0 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
style={{ width: 'calc(100% - 3rem)', marginLeft: '1.5rem' }}
/>
</div>
)}
</div>
)
})}
</div>
{/* Content Area */}
<div className="relative min-h-[300px] bg-zinc-50/50 dark:bg-zinc-900/50 rounded-3xl border border-zinc-100 dark:border-zinc-800 p-8 overflow-hidden">
<AnimatePresence mode='wait' custom={direction}>
{!isCompleted ? (
<motion.div
key={currentStep}
custom={direction}
initial={{ opacity: 0, x: direction > 0 ? 40 : -40, filter: 'blur(10px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: direction > 0 ? -40 : 40, filter: 'blur(10px)' }}
transition={{ duration: 0.4, type: "spring", bounce: 0.2 }}
className="flex flex-col items-center justify-center text-center h-full space-y-6"
>
<div className="w-20 h-20 rounded-3xl bg-white dark:bg-zinc-800 shadow-xl shadow-indigo-500/10 flex items-center justify-center mb-2 ring-1 ring-black/5 dark:ring-white/10">
{React.createElement(steps[currentStep - 1].icon, { className: "w-10 h-10 text-indigo-600 dark:text-indigo-400" })}
</div>
<div>
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white mb-3">{steps[currentStep - 1].title}</h2>
<p className="text-zinc-500 dark:text-zinc-400 max-w-md text-lg leading-relaxed">
Complete the <span className="font-semibold text-indigo-600 dark:text-indigo-400">{steps[currentStep - 1].title.toLowerCase()}</span> step to proceed. This is a placeholder for your actual form content.
</p>
</div>
{/* Mock Form Elements */}
<div className="w-full max-w-xs space-y-3 mt-4">
<div className="h-12 bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 w-full animate-pulse" />
<div className="h-12 bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 w-3/4 mx-auto animate-pulse" style={{ animationDelay: '0.1s' }} />
</div>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center justify-center text-center h-full space-y-6"
>
<div className="w-24 h-24 rounded-full bg-green-500 text-white flex items-center justify-center shadow-2xl shadow-green-500/30 mb-4">
<Check className="w-12 h-12" strokeWidth={4} />
</div>
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white">All Set!</h2>
<p className="text-zinc-500 text-lg">Your account has been successfully created.</p>
<button
onClick={() => window.location.reload()}
className="px-8 py-3 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-full font-bold hover:scale-105 transition-transform"
>
Back to Home
</button>
</motion.div>
)}
</AnimatePresence>
{/* Background decoration */}
<div className="absolute -bottom-20 -right-20 w-64 h-64 bg-indigo-500/5 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -top-20 -left-20 w-64 h-64 bg-purple-500/5 rounded-full blur-3xl pointer-events-none" />
</div>
{/* Controls */}
{!isCompleted && (
<div className="flex justify-between mt-10">
<button
onClick={handlePrev}
disabled={currentStep === 1}
className="px-8 py-3 rounded-2xl text-zinc-500 font-semibold hover:bg-zinc-100 dark:hover:bg-zinc-900 disabled:opacity-30 disabled:cursor-not-allowed transition-all hover:scale-105 active:scale-95"
>
Back
</button>
<button
onClick={handleNext}
className="group relative px-8 py-3 rounded-2xl bg-indigo-600 text-white font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-500/30 hover:shadow-indigo-500/50 hover:scale-105 active:scale-95 flex items-center gap-2"
>
{currentStep === steps.length ? 'Complete Setup' : 'Continue'}
<ChevronRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
</button>
</div>
)}
</div>
)
}
Dependencies
framer-motion: latestlucide-react: latestclsx: latesttailwind-merge: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| steps | Array<{ id: string; title: string; description: string; icon: React.ElementType }> | Predefined steps | Custom steps to render. |
| initialStep | number | 1 | Initial active step (1-indexed). |
| onStepChange | (step: number) => void | undefined | Callback when step changes. |
| className | string | undefined | Additional CSS classes. |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.

