Overview
A usable setup wizard: completed steps swap to a check, panel content slides with a short directional blur, and the shell resizes when step height changes. Occasional frequency — keep motion under ~250ms.
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, {4 useCallback,5 useEffect,6 useLayoutEffect,7 useRef,8 useState,9} from "react";10import { Check } from "lucide-react";11import { cn } from "@/lib/utils";12 13export type StepWizardStep = {14 id: string;15 title: string;16 description?: string;17 content: React.ReactNode;18};19 20export type StepWizardProps = {21 steps: StepWizardStep[];22 step?: number;23 defaultStep?: number;24 onStepChange?: (index: number) => void;25 onComplete?: () => void;26 className?: string;27 nextLabel?: string;28 backLabel?: string;29 finishLabel?: string;30};31 32function readTextSwapDur() {33 const v = parseFloat(34 getComputedStyle(document.documentElement).getPropertyValue(35 "--text-swap-dur",36 ),37 );38 return Number.isFinite(v) ? v : 150;39}40 41export function StepWizard({42 steps,43 step: stepProp,44 defaultStep = 0,45 onStepChange,46 onComplete,47 className,48 nextLabel = "Continue",49 backLabel = "Back",50 finishLabel = "Finish",51}: StepWizardProps) {52 const isControlled = stepProp !== undefined;53 const [uncontrolled, setUncontrolled] = useState(defaultStep);54 const index = isControlled ? stepProp : uncontrolled;55 const clamped = Math.min(Math.max(index, 0), steps.length - 1);56 const current = steps[clamped];57 const isLast = clamped === steps.length - 1;58 const isFirst = clamped === 0;59 const [direction, setDirection] = useState(1);60 const prevIndex = useRef(clamped);61 const shimRef = useRef<HTMLDivElement>(null);62 const [panelHeight, setPanelHeight] = useState<number | undefined>(undefined);63 const primaryLabelRef = useRef<HTMLSpanElement>(null);64 const prevPrimaryLabel = useRef(isLast ? finishLabel : nextLabel);65 const swapTimer = useRef<number | null>(null);66 67 const setStep = useCallback(68 (next: number) => {69 const n = Math.min(Math.max(next, 0), steps.length - 1);70 setDirection(n >= clamped ? 1 : -1);71 prevIndex.current = clamped;72 if (!isControlled) setUncontrolled(n);73 onStepChange?.(n);74 },75 [clamped, isControlled, onStepChange, steps.length],76 );77 78 const measurePanel = useCallback(() => {79 const node = shimRef.current;80 if (!node) return;81 setPanelHeight(node.getBoundingClientRect().height);82 }, []);83 84 useLayoutEffect(() => {85 measurePanel();86 }, [clamped, current, measurePanel]);87 88 useEffect(() => {89 const node = shimRef.current;90 if (!node || typeof ResizeObserver === "undefined") return;91 const ro = new ResizeObserver(() => measurePanel());92 ro.observe(node);93 return () => ro.disconnect();94 }, [measurePanel]);95 96 useEffect(() => {97 const onResize = () => measurePanel();98 window.addEventListener("resize", onResize);99 return () => window.removeEventListener("resize", onResize);100 }, [measurePanel]);101 102 const primaryLabel = isLast ? finishLabel : nextLabel;103 104 useEffect(() => {105 const el = primaryLabelRef.current;106 if (!el) return;107 if (prevPrimaryLabel.current === primaryLabel) {108 el.textContent = primaryLabel;109 return;110 }111 prevPrimaryLabel.current = primaryLabel;112 113 if (swapTimer.current) window.clearTimeout(swapTimer.current);114 const dur = readTextSwapDur();115 el.classList.add("is-exit");116 swapTimer.current = window.setTimeout(() => {117 el.textContent = primaryLabel;118 el.classList.remove("is-exit");119 el.classList.add("is-enter-start");120 void el.offsetHeight;121 el.classList.remove("is-enter-start");122 swapTimer.current = null;123 }, dur);124 125 return () => {126 if (swapTimer.current) window.clearTimeout(swapTimer.current);127 };128 }, [primaryLabel]);129 130 if (!current) return null;131 132 const prev = steps[prevIndex.current] ?? current;133 // Forward: page1=prev (exit L), page2=current. Back: page1=current, page2=prev (exit R).134 const pageAttr = direction >= 0 ? "2" : "1";135 const page1Step = direction >= 0 ? prev : current;136 const page2Step = direction >= 0 ? current : prev;137 const hasTransitioned = prevIndex.current !== clamped;138 139 return (140 <div141 className={cn(142 "w-full overflow-hidden rounded-lg border border-(--color-rule)",143 "bg-(--color-paper)",144 className,145 )}146 >147 {/* Progress track */}148 <div className="border-b border-(--color-rule) px-5 py-4 sm:px-6">149 <ol className="flex items-center gap-1.5">150 {steps.map((s, i) => {151 const done = i < clamped;152 const active = i === clamped;153 return (154 <li key={s.id} className="flex min-w-0 flex-1 items-center gap-1.5">155 <button156 type="button"157 onClick={() => i <= clamped && setStep(i)}158 disabled={i > clamped}159 className={cn(160 "flex min-w-0 items-center gap-2 rounded-sm px-0.5 py-0.5",161 "text-left outline-none",162 "transition-opacity duration-100 ease-[var(--ease-smooth-out)]",163 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",164 "active:scale-[0.97]",165 i > clamped && "opacity-40",166 )}167 >168 <span169 className={cn(170 "relative flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full text-[10px] font-semibold tabular-nums",171 "transition-[background-color,color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)]",172 active173 ? "bg-(--color-ink) text-(--color-paper)"174 : done175 ? "bg-(--color-paper-2) text-(--color-ink) ring-1 ring-(--color-rule)"176 : "bg-(--color-paper-2) text-(--color-ink-muted) ring-1 ring-(--color-rule)",177 )}178 >179 <span180 className="t-icon-swap inline-grid size-full place-items-center"181 data-state={done && !active ? "b" : "a"}182 style={183 {184 "--icon-swap-start-scale": "0.85",185 } as React.CSSProperties186 }187 aria-hidden188 >189 <span190 className="t-icon flex items-center justify-center"191 data-icon="a"192 >193 {i + 1}194 </span>195 <span196 className="t-icon flex items-center justify-center"197 data-icon="b"198 >199 <Check className="size-3 stroke-2" />200 </span>201 </span>202 </span>203 <span204 className={cn(205 "hidden truncate text-xs font-medium sm:block",206 "transition-colors duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)]",207 active208 ? "text-(--color-ink)"209 : "text-(--color-ink-muted)",210 )}211 >212 {s.title}213 </span>214 </button>215 {i < steps.length - 1 && (216 <div217 className={cn(218 "mx-0.5 h-px flex-1 transition-colors duration-[var(--duration-fast)] ease-[var(--ease-out)]",219 done ? "bg-(--color-ink)/25" : "bg-(--color-rule)",220 )}221 />222 )}223 </li>224 );225 })}226 </ol>227 </div>228 229 {/* Panel — direction-aware page slide + height resize */}230 <div231 className="t-resize overflow-hidden"232 style={233 {234 height: panelHeight,235 "--resize-dur": "var(--duration-fast)",236 } as React.CSSProperties237 }238 >239 <div240 className="t-page-slide relative"241 data-page={pageAttr}242 style={243 {244 "--page-exit-enabled": hasTransitioned ? "1" : "0",245 } as React.CSSProperties246 }247 >248 <section className="t-page" data-page-id="1">249 <StepPanel step={page1Step} />250 </section>251 <section className="t-page" data-page-id="2">252 <StepPanel step={page2Step} />253 </section>254 {/* Height shim: absolute pages don't contribute; mirror active content */}255 <div256 ref={shimRef}257 className="invisible pointer-events-none"258 aria-hidden259 >260 <StepPanel step={current} />261 </div>262 </div>263 </div>264 265 {/* Actions */}266 <div className="flex items-center justify-between gap-3 border-t border-(--color-rule) px-5 py-4 sm:px-6">267 <button268 type="button"269 disabled={isFirst}270 onClick={() => setStep(clamped - 1)}271 className={cn(272 "min-h-9 rounded-md px-3 text-sm font-medium",273 "text-(--color-ink-2) outline-none",274 "transition-[opacity,transform,color] duration-100 ease-[var(--ease-smooth-out)]",275 "hover:text-(--color-ink) active:scale-[0.97]",276 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",277 "disabled:pointer-events-none disabled:opacity-30",278 )}279 >280 {backLabel}281 </button>282 <button283 type="button"284 onClick={() => {285 if (isLast) onComplete?.();286 else setStep(clamped + 1);287 }}288 className={cn(289 "inline-flex min-h-9 min-w-24 items-center justify-center rounded-full px-4 text-sm font-medium",290 "bg-(--color-ink) text-(--color-paper) outline-none",291 "transition-transform duration-100 ease-[var(--ease-smooth-out)]",292 "active:scale-[0.97]",293 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",294 )}295 >296 <span ref={primaryLabelRef} className="t-text-swap whitespace-nowrap">297 {primaryLabel}298 </span>299 </button>300 </div>301 </div>302 );303}304 305function StepPanel({ step }: { step: StepWizardStep }) {306 // Padding lives on the panel (not .t-page-slide) because absolute307 // .t-page pages use inset:0 and ignore parent padding.308 return (309 <div className="px-5 py-5 sm:px-6 sm:py-6">310 <h3 className="text-balance text-base font-semibold leading-snug text-(--color-ink)">311 {step.title}312 </h3>313 {step.description ? (314 <p className="mt-2 text-sm leading-relaxed text-(--color-ink-muted)">315 {step.description}316 </p>317 ) : null}318 <div className="mt-5">{step.content}</div>319 </div>320 );321}
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: "a", title: "Project", content: <ProjectForm /> },6 { id: "b", title: "Team", content: <TeamForm /> },7 { id: "c", title: "Review", content: <Review /> },8 ]}9 onComplete={() => createProject()}10/>
Motion
- Page slide (
.t-page-slide): direction-aware enter/exit with blur; first paint disables exit slide via--page-exit-enabled. - Card resize (
.t-resize): measured panel height tweens between steps. - Icon swap (
.t-icon-swap): step number ↔ check on completed steps. - Text swap (
.t-text-swap): Continue ↔ Finish on the primary action. - Reduced motion: snippets zero transform/filter transitions under
prefers-reduced-motion.
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).
- Prefer celebration (success check) in the step content on complete, not a second modal.