Velocity UI
Loading…
Menu

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:

terminal
npx vui-registry-cli-v1 add stepper

Source Code

stepper.tsx
'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: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest

Props

Component property reference.

NameTypeDefaultDescription
stepsArray<{ id: string; title: string; description: string; icon: React.ElementType }>Predefined stepsCustom steps to render.
initialStepnumber1Initial active step (1-indexed).
onStepChange(step: number) => voidundefinedCallback when step changes.
classNamestringundefinedAdditional CSS classes.
Context Worth Keeping In Orbit

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.