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.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/step-wizard.tsx
tsx
"use client";
import React, { useCallback, useId, useState } from "react";import {  AnimatePresence,  motion,  useReducedMotion,} from "motion/react";import { Check } from "lucide-react";import { cn } from "@/lib/utils";
export type StepWizardStep = {  id: string;  title: string;  description?: string;  content: React.ReactNode;};
export type StepWizardProps = {  steps: StepWizardStep[];  step?: number;  defaultStep?: number;  onStepChange?: (index: number) => void;  onComplete?: () => void;  className?: string;  nextLabel?: string;  backLabel?: string;  finishLabel?: string;};
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };const EASE_OUT = [0.16, 1, 0.3, 1] as const;const ICON_SPRING = { type: "spring" as const, bounce: 0, duration: 0.3 };
export function StepWizard({  steps,  step: stepProp,  defaultStep = 0,  onStepChange,  onComplete,  className,  nextLabel = "Continue",  backLabel = "Back",  finishLabel = "Finish",}: StepWizardProps) {  const reduce = useReducedMotion();  const layoutId = useId();  const isControlled = stepProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(defaultStep);  const index = isControlled ? stepProp : uncontrolled;  const clamped = Math.min(Math.max(index, 0), steps.length - 1);  const current = steps[clamped];  const isLast = clamped === steps.length - 1;  const isFirst = clamped === 0;  const [direction, setDirection] = useState(1);
  const setStep = useCallback(    (next: number) => {      const n = Math.min(Math.max(next, 0), steps.length - 1);      setDirection(n >= clamped ? 1 : -1);      if (!isControlled) setUncontrolled(n);      onStepChange?.(n);    },    [clamped, isControlled, onStepChange, steps.length],  );
  if (!current) return null;
  return (    <div      className={cn(        "w-full overflow-hidden rounded-lg border border-(--color-rule)",        "bg-(--color-paper)",        className,      )}    >      {/* Progress track */}      <div className="border-b border-(--color-rule) px-4 py-3">        <ol className="flex items-center gap-1">          {steps.map((s, i) => {            const done = i < clamped;            const active = i === clamped;            return (              <li key={s.id} className="flex min-w-0 flex-1 items-center gap-1">                <button                  type="button"                  onClick={() => i <= clamped && setStep(i)}                  disabled={i > clamped}                  className={cn(                    "flex min-w-0 items-center gap-2 rounded-sm px-1 py-1",                    "text-left outline-none",                    "transition-opacity duration-(--dur-micro) ease-out",                    "focus-visible:ring-2 focus-visible:ring-(--color-focus)",                    i > clamped && "opacity-40",                  )}                >                  <span className="relative flex size-6 shrink-0 items-center justify-center">                    {active && (                      <motion.span                        layoutId={`${layoutId}-dot`}                        className="absolute inset-0 rounded-full bg-(--color-ink)"                        transition={reduce ? { duration: 0.15 } : SPRING}                      />                    )}                    <span                      className={cn(                        "relative z-1 flex size-6 items-center justify-center overflow-hidden rounded-full text-[10px] font-semibold tabular-nums",                        active                          ? "text-(--color-paper)"                          : done                            ? "bg-(--color-paper-2) text-(--color-ink) ring-1 ring-(--color-rule)"                            : "bg-(--color-paper-2) text-(--color-ink-muted) ring-1 ring-(--color-rule)",                      )}                    >                      <AnimatePresence mode="wait" initial={false}>                        <motion.span                          key={done && !active ? "check" : "num"}                          initial={                            reduce                              ? { opacity: 0 }                              : {                                  opacity: 0,                                  scale: 0.25,                                  filter: "blur(4px)",                                }                          }                          animate={{                            opacity: 1,                            scale: 1,                            filter: "blur(0px)",                          }}                          exit={                            reduce                              ? { opacity: 0 }                              : {                                  opacity: 0,                                  scale: 0.25,                                  filter: "blur(4px)",                                }                          }                          transition={                            reduce ? { duration: 0.1 } : ICON_SPRING                          }                          className="flex items-center justify-center"                        >                          {done && !active ? (                            <Check className="size-3 stroke-2" />                          ) : (                            i + 1                          )}                        </motion.span>                      </AnimatePresence>                    </span>                  </span>                  <span                    className={cn(                      "hidden truncate text-xs font-medium sm:block",                      active                        ? "text-(--color-ink)"                        : "text-(--color-ink-muted)",                    )}                  >                    {s.title}                  </span>                </button>                {i < steps.length - 1 && (                  <div className="mx-0.5 h-px flex-1 bg-(--color-rule)" />                )}              </li>            );          })}        </ol>      </div>
      {/* Panel */}      <div className="relative min-h-[200px] px-5 py-5">        <AnimatePresence mode="wait" initial={false} custom={direction}>          <motion.div            key={current.id}            custom={direction}            initial={              reduce                ? { opacity: 0 }                : { opacity: 0, x: direction * 16, filter: "blur(4px)" }            }            animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}            exit={              reduce                ? { opacity: 0 }                : {                    opacity: 0,                    x: direction * -12,                    filter: "blur(4px)",                    transition: { duration: 0.16 },                  }            }            transition={              reduce                ? { duration: 0.14 }                : { duration: 0.28, ease: EASE_OUT }            }          >            <h3 className="text-balance text-base font-semibold text-(--color-ink)">              {current.title}            </h3>            {current.description ? (              <p className="mt-1 text-sm text-(--color-ink-muted)">                {current.description}              </p>            ) : null}            <div className="mt-4">{current.content}</div>          </motion.div>        </AnimatePresence>      </div>
      {/* Actions */}      <div className="flex items-center justify-between gap-3 border-t border-(--color-rule) px-4 py-3">        <motion.button          type="button"          disabled={isFirst}          onClick={() => setStep(clamped - 1)}          whileTap={isFirst || reduce ? undefined : { scale: 0.96 }}          className={cn(            "min-h-10 rounded-md px-3 text-sm font-medium",            "text-(--color-ink-2) outline-none",            "transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",            "hover:text-(--color-ink)",            "focus-visible:ring-2 focus-visible:ring-(--color-focus)",            "disabled:pointer-events-none disabled:opacity-30",          )}        >          {backLabel}        </motion.button>        <motion.button          type="button"          onClick={() => {            if (isLast) onComplete?.();            else setStep(clamped + 1);          }}          whileTap={reduce ? undefined : { scale: 0.96 }}          className={cn(            "min-h-10 rounded-md px-4 text-sm font-medium",            "bg-(--color-ink) text-(--color-paper) outline-none",            "transition-transform duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",            "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2",          )}        >          {isLast ? finishLabel : nextLabel}        </motion.button>      </div>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { StepWizard } from "@/components/ui/step-wizard";
<StepWizard  steps={[    { id: "a", title: "Workspace", content: <WorkspaceForm /> },    { id: "b", title: "Members", content: <MembersForm /> },    { id: "c", title: "Review", content: <Review /> },  ]}  onComplete={() => createWorkspace()}/>

Props

PropTypeRequiredDefaultDescription
stepsStepWizardStep[]YesSteps with id, title, optional description, and content.
stepnumberNoControlled step index.
defaultStepnumberNo0Uncontrolled initial index.
onStepChange(index: number) => voidNoFires when the active step changes.
onComplete() => voidNoFires when Finish is pressed on the last step.

Best Practices

  1. Keep panels light — forms, not full pages — so the wait-mode transition stays under ~300ms.
  2. Allow clicking completed steps to go back; keep future steps disabled.
  3. Panel motion is direction-aware (back reverses the path); use initial={false}.
  4. Reduced motion: opacity only, no x / blur. Check/number swaps use icon scale+blur.