Overview
A usable onboarding / setup wizard: progress dots share a layoutId pill, completed steps show a check, and panel content cross-fades with a short directional blur. Occasional frequency — standard spring budget.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/step-wizard.jsonInstall dependencies:
$ pnpm add motion lucide-reactAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useId, useState } from "react";4import {5 AnimatePresence,6 motion,7 useReducedMotion,8} from "motion/react";9import { Check } from "lucide-react";10import { cn } from "@/lib/utils";11 12export type StepWizardStep = {13 id: string;14 title: string;15 description?: string;16 content: React.ReactNode;17};18 19export type StepWizardProps = {20 steps: StepWizardStep[];21 step?: number;22 defaultStep?: number;23 onStepChange?: (index: number) => void;24 onComplete?: () => void;25 className?: string;26 nextLabel?: string;27 backLabel?: string;28 finishLabel?: string;29};30 31const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };32const EASE_OUT = [0.16, 1, 0.3, 1] as const;33const ICON_SPRING = { type: "spring" as const, bounce: 0, duration: 0.3 };34 35export function StepWizard({36 steps,37 step: stepProp,38 defaultStep = 0,39 onStepChange,40 onComplete,41 className,42 nextLabel = "Continue",43 backLabel = "Back",44 finishLabel = "Finish",45}: StepWizardProps) {46 const reduce = useReducedMotion();47 const layoutId = useId();48 const isControlled = stepProp !== undefined;49 const [uncontrolled, setUncontrolled] = useState(defaultStep);50 const index = isControlled ? stepProp : uncontrolled;51 const clamped = Math.min(Math.max(index, 0), steps.length - 1);52 const current = steps[clamped];53 const isLast = clamped === steps.length - 1;54 const isFirst = clamped === 0;55 const [direction, setDirection] = useState(1);56 57 const setStep = useCallback(58 (next: number) => {59 const n = Math.min(Math.max(next, 0), steps.length - 1);60 setDirection(n >= clamped ? 1 : -1);61 if (!isControlled) setUncontrolled(n);62 onStepChange?.(n);63 },64 [clamped, isControlled, onStepChange, steps.length],65 );66 67 if (!current) return null;68 69 return (70 <div71 className={cn(72 "w-full overflow-hidden rounded-lg border border-(--color-rule)",73 "bg-(--color-paper)",74 className,75 )}76 >77 {/* Progress track */}78 <div className="border-b border-(--color-rule) px-4 py-3">79 <ol className="flex items-center gap-1">80 {steps.map((s, i) => {81 const done = i < clamped;82 const active = i === clamped;83 return (84 <li key={s.id} className="flex min-w-0 flex-1 items-center gap-1">85 <button86 type="button"87 onClick={() => i <= clamped && setStep(i)}88 disabled={i > clamped}89 className={cn(90 "flex min-w-0 items-center gap-2 rounded-sm px-1 py-1",91 "text-left outline-none",92 "transition-opacity duration-(--dur-micro) ease-out",93 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",94 i > clamped && "opacity-40",95 )}96 >97 <span className="relative flex size-6 shrink-0 items-center justify-center">98 {active && (99 <motion.span100 layoutId={`${layoutId}-dot`}101 className="absolute inset-0 rounded-full bg-(--color-ink)"102 transition={reduce ? { duration: 0.15 } : SPRING}103 />104 )}105 <span106 className={cn(107 "relative z-1 flex size-6 items-center justify-center overflow-hidden rounded-full text-[10px] font-semibold tabular-nums",108 active109 ? "text-(--color-paper)"110 : done111 ? "bg-(--color-paper-2) text-(--color-ink) ring-1 ring-(--color-rule)"112 : "bg-(--color-paper-2) text-(--color-ink-muted) ring-1 ring-(--color-rule)",113 )}114 >115 <AnimatePresence mode="wait" initial={false}>116 <motion.span117 key={done && !active ? "check" : "num"}118 initial={119 reduce120 ? { opacity: 0 }121 : {122 opacity: 0,123 scale: 0.25,124 filter: "blur(4px)",125 }126 }127 animate={{128 opacity: 1,129 scale: 1,130 filter: "blur(0px)",131 }}132 exit={133 reduce134 ? { opacity: 0 }135 : {136 opacity: 0,137 scale: 0.25,138 filter: "blur(4px)",139 }140 }141 transition={142 reduce ? { duration: 0.1 } : ICON_SPRING143 }144 className="flex items-center justify-center"145 >146 {done && !active ? (147 <Check className="size-3 stroke-2" />148 ) : (149 i + 1150 )}151 </motion.span>152 </AnimatePresence>153 </span>154 </span>155 <span156 className={cn(157 "hidden truncate text-xs font-medium sm:block",158 active159 ? "text-(--color-ink)"160 : "text-(--color-ink-muted)",161 )}162 >163 {s.title}164 </span>165 </button>166 {i < steps.length - 1 && (167 <div className="mx-0.5 h-px flex-1 bg-(--color-rule)" />168 )}169 </li>170 );171 })}172 </ol>173 </div>174 175 {/* Panel */}176 <div className="relative min-h-[200px] px-5 py-5">177 <AnimatePresence mode="wait" initial={false} custom={direction}>178 <motion.div179 key={current.id}180 custom={direction}181 initial={182 reduce183 ? { opacity: 0 }184 : { opacity: 0, x: direction * 16, filter: "blur(4px)" }185 }186 animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}187 exit={188 reduce189 ? { opacity: 0 }190 : {191 opacity: 0,192 x: direction * -12,193 filter: "blur(4px)",194 transition: { duration: 0.16 },195 }196 }197 transition={198 reduce199 ? { duration: 0.14 }200 : { duration: 0.28, ease: EASE_OUT }201 }202 >203 <h3 className="text-balance text-base font-semibold text-(--color-ink)">204 {current.title}205 </h3>206 {current.description ? (207 <p className="mt-1 text-sm text-(--color-ink-muted)">208 {current.description}209 </p>210 ) : null}211 <div className="mt-4">{current.content}</div>212 </motion.div>213 </AnimatePresence>214 </div>215 216 {/* Actions */}217 <div className="flex items-center justify-between gap-3 border-t border-(--color-rule) px-4 py-3">218 <motion.button219 type="button"220 disabled={isFirst}221 onClick={() => setStep(clamped - 1)}222 whileTap={isFirst || reduce ? undefined : { scale: 0.96 }}223 className={cn(224 "min-h-10 rounded-md px-3 text-sm font-medium",225 "text-(--color-ink-2) outline-none",226 "transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",227 "hover:text-(--color-ink)",228 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",229 "disabled:pointer-events-none disabled:opacity-30",230 )}231 >232 {backLabel}233 </motion.button>234 <motion.button235 type="button"236 onClick={() => {237 if (isLast) onComplete?.();238 else setStep(clamped + 1);239 }}240 whileTap={reduce ? undefined : { scale: 0.96 }}241 className={cn(242 "min-h-10 rounded-md px-4 text-sm font-medium",243 "bg-(--color-ink) text-(--color-paper) outline-none",244 "transition-transform duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",245 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2",246 )}247 >248 {isLast ? finishLabel : nextLabel}249 </motion.button>250 </div>251 </div>252 );253}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { StepWizard } from "@/components/ui/step-wizard";2 3<StepWizard4 steps={[5 { id: "a", title: "Workspace", content: <WorkspaceForm /> },6 { id: "b", title: "Members", content: <MembersForm /> },7 { id: "c", title: "Review", content: <Review /> },8 ]}9 onComplete={() => createWorkspace()}10/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| steps | StepWizardStep[] | Yes | — | Steps with id, title, optional description, and content. |
| step | number | No | — | Controlled step index. |
| defaultStep | number | No | 0 | Uncontrolled initial index. |
| onStepChange | (index: number) => void | No | — | Fires when the active step changes. |
| onComplete | () => void | No | — | Fires when Finish is pressed on the last step. |
Best Practices
- Keep panels light — forms, not full pages — so the wait-mode transition stays under ~300ms.
- Allow clicking completed steps to go back; keep future steps disabled.
- Panel motion is direction-aware (back reverses the path); use
initial={false}. - Reduced motion: opacity only, no x / blur. Check/number swaps use icon scale+blur.