Overview
A multi-step card with a progress rail across the top: the dark line fills toward the current step, finished steps draw a check, and a halo slides to the active step. Finished steps can be clicked to go back. Each step slides in from the direction you're heading, through a soft blur, while the card's height follows its content. The primary button's label crossfades from Continue to the finish label. A step can hold the user with canContinue. If onComplete returns a promise, the button shows a busy label until it settles, then the wizard can swap to a completion screen.
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 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, useEffect, useId, useRef, useState } from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import { ArrowLeft } from "lucide-react";6import { cn } from "@/lib/utils";7 8export type StepWizardStep = {9 id: string;10 title: string;11 description?: string;12 content: React.ReactNode;13 /** Set to false to hold the user on this step (e.g. a required field is empty). */14 canContinue?: boolean;15};16 17export type StepWizardProps = {18 steps: StepWizardStep[];19 /** Controlled step index. */20 step?: number;21 defaultStep?: number;22 onStepChange?: (index: number) => void;23 /** Called from the last step. Return a promise to show a busy state until it settles. */24 onComplete?: () => void | Promise<unknown>;25 /** Shown in place of the steps once `onComplete` resolves. Receives a reset function. */26 completeContent?: React.ReactNode | ((reset: () => void) => React.ReactNode);27 className?: string;28 nextLabel?: string;29 backLabel?: string;30 finishLabel?: string;31};32 33const EASE_OUT = [0.23, 1, 0.32, 1] as const;34const HEIGHT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.42 };35 36function NodeGlyph({ index, done, reduce }: { index: number; done: boolean; reduce: boolean }) {37 return (38 <AnimatePresence initial={false} mode="popLayout">39 {done ? (40 <motion.svg41 key="check"42 viewBox="0 0 16 16"43 className="size-3.5"44 fill="none"45 stroke="currentColor"46 strokeWidth="2.4"47 strokeLinecap="round"48 strokeLinejoin="round"49 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}50 animate={{ opacity: 1, scale: 1 }}51 exit={{ opacity: 0, scale: 0.5 }}52 transition={{ type: "spring", bounce: 0.35, duration: 0.4 }}53 >54 <motion.path55 d="M3.5 8.5l3 3 6-7"56 initial={{ pathLength: reduce ? 1 : 0 }}57 animate={{ pathLength: 1 }}58 transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.05 }}59 />60 </motion.svg>61 ) : (62 <motion.span63 key="num"64 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(2px)" }}65 animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}66 exit={{ opacity: 0, scale: 0.5 }}67 transition={{ duration: 0.25, ease: EASE_OUT }}68 >69 {index + 1}70 </motion.span>71 )}72 </AnimatePresence>73 );74}75 76export function StepWizard({77 steps,78 step: stepProp,79 defaultStep = 0,80 onStepChange,81 onComplete,82 completeContent,83 className,84 nextLabel = "Continue",85 backLabel = "Back",86 finishLabel = "Finish",87}: StepWizardProps) {88 const reduce = !!useReducedMotion();89 const uid = useId();90 const isControlled = stepProp !== undefined;91 const [uncontrolled, setUncontrolled] = useState(defaultStep);92 const last = steps.length - 1;93 const index = Math.min(Math.max(isControlled ? stepProp : uncontrolled, 0), last);94 const [direction, setDirection] = useState(1);95 const [busy, setBusy] = useState(false);96 const [complete, setComplete] = useState(false);97 const mountedRef = useRef(true);98 useEffect(() => {99 mountedRef.current = true;100 return () => {101 mountedRef.current = false;102 };103 }, []);104 105 // Panel height follows its content, including content that changes later.106 const innerRef = useRef<HTMLDivElement>(null);107 const [height, setHeight] = useState<number | "auto">("auto");108 useEffect(() => {109 const el = innerRef.current;110 if (!el) return;111 const ro = new ResizeObserver(([entry]) => setHeight(entry.contentRect.height));112 ro.observe(el);113 return () => ro.disconnect();114 }, []);115 116 const current = steps[index];117 const isLast = index === last;118 const blocked = current?.canContinue === false;119 120 const setStep = useCallback(121 (next: number) => {122 const n = Math.min(Math.max(next, 0), last);123 if (n === index) return;124 setDirection(n > index ? 1 : -1);125 if (!isControlled) setUncontrolled(n);126 onStepChange?.(n);127 },128 [index, isControlled, last, onStepChange],129 );130 131 const finish = async () => {132 if (busy) return;133 const result = onComplete?.();134 if (result && typeof (result as Promise<unknown>).then === "function") {135 setBusy(true);136 try {137 await result;138 } catch {139 if (mountedRef.current) setBusy(false);140 return;141 }142 if (!mountedRef.current) return;143 setBusy(false);144 }145 if (completeContent !== undefined) {146 setDirection(1);147 setComplete(true);148 }149 };150 151 const reset = useCallback(() => {152 setDirection(-1);153 setComplete(false);154 if (!isControlled) setUncontrolled(0);155 onStepChange?.(0);156 }, [isControlled, onStepChange]);157 158 if (!current) return null;159 160 const progress = complete ? 1 : last === 0 ? 1 : index / last;161 const primaryLabel = busy ? "Working…" : isLast ? finishLabel : nextLabel;162 const panelKey = complete ? "__complete" : current.id;163 164 const variants = {165 enter: (dir: number) =>166 reduce ? { opacity: 0 } : { opacity: 0, x: dir * 36, filter: "blur(6px)" },167 center: { opacity: 1, x: 0, filter: "blur(0px)" },168 exit: (dir: number) =>169 reduce ? { opacity: 0 } : { opacity: 0, x: dir * -36, filter: "blur(6px)" },170 };171 172 return (173 <div174 className={cn(175 "w-full overflow-hidden rounded-[20px] bg-(--color-paper)",176 "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_12px_32px_-12px_rgba(0,0,0,0.12)]",177 className,178 )}179 >180 {/* Track */}181 <div className="px-5 pt-5 sm:px-6">182 <div className="mb-3 flex items-center justify-between text-[12px] text-(--color-neutral) tabular-nums">183 <span aria-hidden>184 {complete ? "All done" : `Step ${index + 1} of ${steps.length}`}185 </span>186 <span aria-hidden>{Math.round(progress * 100)}%</span>187 </div>188 <div role="list" className="relative flex items-center justify-between" aria-label="Progress">189 {/* Rail + fill sit behind the nodes */}190 <div aria-hidden className="absolute inset-x-3 top-1/2 h-[2px] -translate-y-1/2 rounded-full bg-(--color-rule)">191 <motion.div192 className="h-full origin-left rounded-full bg-(--color-ink)"193 initial={false}194 animate={{ scaleX: progress }}195 transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0, duration: 0.6 }}196 />197 </div>198 {steps.map((s, i) => {199 const done = complete || i < index;200 const active = !complete && i === index;201 const reachable = !complete && !busy && i < index;202 return (203 <div role="listitem" key={s.id} className="relative z-1">204 <button205 type="button"206 aria-current={active ? "step" : undefined}207 aria-label={`${s.title}${active ? ", current step" : done ? ", completed" : ""}`}208 disabled={!reachable && !active}209 onClick={() => reachable && setStep(i)}210 className={cn(211 "group relative flex size-7 items-center justify-center rounded-full text-[11.5px] font-semibold tabular-nums outline-none",212 "transition-[background-color,color,box-shadow,transform] duration-300 ease-out",213 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",214 reachable && "cursor-pointer hover:scale-110 active:scale-95",215 done || active216 ? "bg-(--color-ink) text-(--color-paper)"217 : "bg-(--color-paper) text-(--color-neutral) shadow-[inset_0_0_0_1.5px_var(--color-rule)]",218 )}219 >220 {active && !reduce ? (221 <motion.span222 layoutId={`${uid}-halo`}223 aria-hidden224 transition={{ type: "spring", bounce: 0.2, duration: 0.5 }}225 className="absolute -inset-[5px] rounded-full shadow-[0_0_0_1.5px_var(--color-ink)] opacity-20"226 />227 ) : null}228 <NodeGlyph index={i} done={done} reduce={reduce} />229 <span className="sr-only">{s.title}</span>230 </button>231 </div>232 );233 })}234 </div>235 </div>236 237 {/* Panel */}238 <motion.div239 initial={false}240 animate={{ height }}241 transition={reduce ? { duration: 0 } : HEIGHT_SPRING}242 className="relative overflow-hidden"243 >244 <div ref={innerRef} className="relative">245 <AnimatePresence initial={false} mode="popLayout" custom={direction}>246 <motion.section247 key={panelKey}248 custom={direction}249 variants={variants}250 initial="enter"251 animate="center"252 exit="exit"253 transition={{ duration: 0.34, ease: EASE_OUT }}254 aria-labelledby={complete ? undefined : `${uid}-title`}255 className="px-5 pt-5 pb-6 sm:px-6"256 >257 {complete ? (258 typeof completeContent === "function" ? (259 completeContent(reset)260 ) : (261 completeContent262 )263 ) : (264 <>265 <div266 id={`${uid}-title`}267 className="text-[17px] leading-snug font-semibold tracking-[-0.01em] text-balance text-(--color-ink)"268 >269 {current.title}270 </div>271 {current.description ? (272 <div className="mt-1 text-[13.5px] leading-relaxed text-(--color-neutral)">273 {current.description}274 </div>275 ) : null}276 <motion.div277 initial={reduce ? false : { opacity: 0, y: 8 }}278 animate={{ opacity: 1, y: 0 }}279 transition={{ duration: 0.35, ease: EASE_OUT, delay: 0.08 }}280 className="mt-5"281 >282 {current.content}283 </motion.div>284 </>285 )}286 </motion.section>287 </AnimatePresence>288 </div>289 </motion.div>290 291 {/* Actions */}292 <AnimatePresence initial={false}>293 {!complete ? (294 <motion.div295 initial={{ height: 0, opacity: 0 }}296 animate={{ height: "auto", opacity: 1 }}297 exit={{ height: 0, opacity: 0 }}298 transition={reduce ? { duration: 0 } : HEIGHT_SPRING}299 className="overflow-hidden"300 >301 <div className="flex items-center justify-between gap-3 border-t border-(--color-rule) px-5 py-3.5 sm:px-6">302 <motion.button303 type="button"304 disabled={index === 0 || busy}305 onClick={() => setStep(index - 1)}306 animate={{ opacity: index === 0 ? 0 : 1, x: index === 0 && !reduce ? -6 : 0 }}307 transition={{ duration: 0.25, ease: EASE_OUT }}308 className={cn(309 "inline-flex h-9 items-center gap-1.5 rounded-full px-3 text-[13.5px] font-medium text-(--color-ink-2) outline-none",310 "transition-[background-color,color,transform] duration-150 hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-[0.96]",311 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",312 "disabled:pointer-events-none",313 )}314 >315 <ArrowLeft className="size-3.5" strokeWidth={2} aria-hidden />316 {backLabel}317 </motion.button>318 <motion.button319 type="button"320 layout={reduce ? false : true}321 disabled={blocked || busy}322 onClick={() => (isLast ? finish() : setStep(index + 1))}323 transition={{ type: "spring", bounce: 0.15, duration: 0.4 }}324 className={cn(325 "relative inline-flex h-9 min-w-24 items-center justify-center overflow-hidden rounded-full px-4 text-[13.5px] font-medium outline-none",326 "bg-(--color-ink) text-(--color-paper) shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_1px_2px_rgba(0,0,0,0.12)]",327 "transition-[opacity,transform] duration-150 active:scale-[0.97]",328 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",329 "disabled:cursor-not-allowed",330 blocked && "opacity-40",331 )}332 >333 <AnimatePresence initial={false} mode="popLayout">334 <motion.span335 key={primaryLabel}336 layout="position"337 initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10, filter: "blur(4px)" }}338 animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}339 exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10, filter: "blur(4px)" }}340 transition={{ duration: 0.26, ease: EASE_OUT }}341 className="inline-block whitespace-nowrap"342 >343 {primaryLabel}344 </motion.span>345 </AnimatePresence>346 </motion.button>347 </div>348 </motion.div>349 ) : null}350 </AnimatePresence>351 352 <div role="status" aria-live="polite" className="sr-only">353 {complete ? "Completed" : `Step ${index + 1} of ${steps.length}: ${current.title}`}354 </div>355 </div>356 );357}
Adjust import paths according to your folder structure.
Ensure transitions.dev CSS is available (.t-page-slide, .t-icon-swap,
.t-resize, .t-text-swap). This site imports them from
styles/transitions-dev/.
Basic Usage
tsx
1import { StepWizard } from "@/components/ui/step-wizard";2 3<StepWizard4 steps={[5 { id: "name", title: "Name your project", content: <NameField />, canContinue: name.length > 1 },6 { id: "team", title: "Invite your team", content: <TeamPicker /> },7 { id: "review", title: "Review and create", content: <Summary /> },8 ]}9 finishLabel="Create project"10 onComplete={() => createProject()} // may return a promise11 completeContent={(reset) => <Done onStartOver={reset} />}12/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| steps | StepWizardStep[] | Yes | — | Steps with id, title, optional description, content, and canContinue (false disables the primary button). |
| step / defaultStep | number | No | 0 | Controlled or initial step index. |
| onStepChange | (index: number) => void | No | — | Fires on Continue, Back, a click on a finished step, or a reset. |
| onComplete | () => void | Promise<unknown> | No | — | Runs from the last step. A returned promise shows a busy state; a rejection keeps the user on the last step. |
| completeContent | ReactNode | (reset: () => void) => ReactNode | No | — | Shown after onComplete succeeds. The function form gets a reset that returns to step one. |
| nextLabel / backLabel / finishLabel | string | No | "Continue" / "Back" / "Finish" | Button labels. |
| className | string | No | — | Class names on the card. |
Best Practices
- Use
canContinuefor required input rather than showing errors after the click. - Keep each step to one decision, and put a summary on the last step.
- Return the real request from
onComplete, so the busy state matches the actual wait. - Step changes are announced to screen readers. Reduced motion swaps the slides for fades.