shadimage

Stepper

Capsule progress tracker and numbered step pills.

components/ui/stepper.tsx

import * as React from 'react'
import { motion } from 'motion/react'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'

export interface Step {
  id: number | string
  label: string
  description?: string
}

export interface StepperProps {
  steps: Step[]
  currentStep: number
  onStepClick?: (stepId: number | string, index: number) => void
  className?: string
}

export function Stepper({ steps, currentStep, onStepClick, className }: StepperProps) {
  return (
    <div role="list" aria-label="Progress steps" className={cn('w-full space-y-4 font-sans', className)}>
      <div className="flex items-center justify-between gap-2">
        {steps.map((step, index) => {
          const stepNumber = index + 1
          const isCompleted = stepNumber < currentStep
          const isCurrent = stepNumber === currentStep
          const isClickable = typeof onStepClick === 'function'

          return (
            <React.Fragment key={step.id}>
              <div className="flex items-center gap-2">
                <motion.button
                  type="button"
                  disabled={!isClickable}
                  onClick={() => isClickable && onStepClick(step.id, index)}
                  aria-current={isCurrent ? 'step' : undefined}
                  aria-label={`Step ${stepNumber}: ${step.label}${isCompleted ? ' (completed)' : ''}`}
                  className={cn(
                    'group flex items-center gap-2 rounded-full p-1 pr-2 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2',
                    isClickable && 'cursor-pointer'
                  )}
                >
                  <motion.div
                    initial={false}
                    animate={{
                      scale: isCurrent ? 1.1 : 1,
                    }}
                    transition={{ type: 'spring', stiffness: 500, damping: 25 }}
                    className={cn(
                      'w-9 h-9 rounded-full rk-border-sm flex items-center justify-center font-mono font-black text-sm rk-shadow-sm transition-colors duration-200 select-none shrink-0 aspect-square p-0',
                      isCompleted
                        ? 'bg-rk-success text-rk-ink'
                        : isCurrent
                        ? 'bg-rk-primary text-rk-ink'
                        : 'bg-rk-surface text-rk-ink/50',
                      isClickable && !isCurrent && !isCompleted && 'group-hover:bg-rk-primary/60'
                    )}
                  >
                    {isCompleted ? <Check className="w-5 h-5 stroke-[3.5]" aria-hidden="true" /> : stepNumber}
                  </motion.div>
                  <div className="hidden sm:block text-left">
                    <div
                      className={cn(
                        'font-extrabold text-xs leading-tight',
                        isCurrent ? 'text-rk-ink' : 'text-rk-ink/60'
                      )}
                    >
                      {step.label}
                    </div>
                    {step.description && (
                      <div className="text-[10px] font-bold text-rk-ink/40">{step.description}</div>
                    )}
                  </div>
                </motion.button>
              </div>
              {index < steps.length - 1 && (
                <div className="flex-1 h-2 bg-rk-surface rounded-full rk-border-sm relative overflow-hidden" aria-hidden="true">
                  <motion.div
                    initial={{ width: '0%' }}
                    animate={{ width: isCompleted ? '100%' : '0%' }}
                    transition={{ duration: 0.3, ease: 'easeOut' }}
                    className="h-full bg-rk-success"
                  />
                </div>
              )}
            </React.Fragment>
          )
        })}
      </div>
    </div>
  )
}
Where it goes
onboarding

DependenciesBundle weight · light

icons
lucide-react