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

Install dependencies:

$ pnpm add 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,  useEffect,  useLayoutEffect,  useRef,  useState,} from "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;};
function readTextSwapDur() {  const v = parseFloat(    getComputedStyle(document.documentElement).getPropertyValue(      "--text-swap-dur",    ),  );  return Number.isFinite(v) ? v : 150;}
export function StepWizard({  steps,  step: stepProp,  defaultStep = 0,  onStepChange,  onComplete,  className,  nextLabel = "Continue",  backLabel = "Back",  finishLabel = "Finish",}: StepWizardProps) {  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 prevIndex = useRef(clamped);  const shimRef = useRef<HTMLDivElement>(null);  const [panelHeight, setPanelHeight] = useState<number | undefined>(undefined);  const primaryLabelRef = useRef<HTMLSpanElement>(null);  const prevPrimaryLabel = useRef(isLast ? finishLabel : nextLabel);  const swapTimer = useRef<number | null>(null);
  const setStep = useCallback(    (next: number) => {      const n = Math.min(Math.max(next, 0), steps.length - 1);      setDirection(n >= clamped ? 1 : -1);      prevIndex.current = clamped;      if (!isControlled) setUncontrolled(n);      onStepChange?.(n);    },    [clamped, isControlled, onStepChange, steps.length],  );
  const measurePanel = useCallback(() => {    const node = shimRef.current;    if (!node) return;    setPanelHeight(node.getBoundingClientRect().height);  }, []);
  useLayoutEffect(() => {    measurePanel();  }, [clamped, current, measurePanel]);
  useEffect(() => {    const node = shimRef.current;    if (!node || typeof ResizeObserver === "undefined") return;    const ro = new ResizeObserver(() => measurePanel());    ro.observe(node);    return () => ro.disconnect();  }, [measurePanel]);
  useEffect(() => {    const onResize = () => measurePanel();    window.addEventListener("resize", onResize);    return () => window.removeEventListener("resize", onResize);  }, [measurePanel]);
  const primaryLabel = isLast ? finishLabel : nextLabel;
  useEffect(() => {    const el = primaryLabelRef.current;    if (!el) return;    if (prevPrimaryLabel.current === primaryLabel) {      el.textContent = primaryLabel;      return;    }    prevPrimaryLabel.current = primaryLabel;
    if (swapTimer.current) window.clearTimeout(swapTimer.current);    const dur = readTextSwapDur();    el.classList.add("is-exit");    swapTimer.current = window.setTimeout(() => {      el.textContent = primaryLabel;      el.classList.remove("is-exit");      el.classList.add("is-enter-start");      void el.offsetHeight;      el.classList.remove("is-enter-start");      swapTimer.current = null;    }, dur);
    return () => {      if (swapTimer.current) window.clearTimeout(swapTimer.current);    };  }, [primaryLabel]);
  if (!current) return null;
  const prev = steps[prevIndex.current] ?? current;  // Forward: page1=prev (exit L), page2=current. Back: page1=current, page2=prev (exit R).  const pageAttr = direction >= 0 ? "2" : "1";  const page1Step = direction >= 0 ? prev : current;  const page2Step = direction >= 0 ? current : prev;  const hasTransitioned = prevIndex.current !== clamped;
  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-5 py-4 sm:px-6">        <ol className="flex items-center gap-1.5">          {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.5">                <button                  type="button"                  onClick={() => i <= clamped && setStep(i)}                  disabled={i > clamped}                  className={cn(                    "flex min-w-0 items-center gap-2 rounded-sm px-0.5 py-0.5",                    "text-left outline-none",                    "transition-opacity duration-100 ease-[var(--ease-smooth-out)]",                    "focus-visible:ring-2 focus-visible:ring-(--color-focus)",                    "active:scale-[0.97]",                    i > clamped && "opacity-40",                  )}                >                  <span                    className={cn(                      "relative flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full text-[10px] font-semibold tabular-nums",                      "transition-[background-color,color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)]",                      active                        ? "bg-(--color-ink) 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)",                    )}                  >                    <span                      className="t-icon-swap inline-grid size-full place-items-center"                      data-state={done && !active ? "b" : "a"}                      style={                        {                          "--icon-swap-start-scale": "0.85",                        } as React.CSSProperties                      }                      aria-hidden                    >                      <span                        className="t-icon flex items-center justify-center"                        data-icon="a"                      >                        {i + 1}                      </span>                      <span                        className="t-icon flex items-center justify-center"                        data-icon="b"                      >                        <Check className="size-3 stroke-2" />                      </span>                    </span>                  </span>                  <span                    className={cn(                      "hidden truncate text-xs font-medium sm:block",                      "transition-colors duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)]",                      active                        ? "text-(--color-ink)"                        : "text-(--color-ink-muted)",                    )}                  >                    {s.title}                  </span>                </button>                {i < steps.length - 1 && (                  <div                    className={cn(                      "mx-0.5 h-px flex-1 transition-colors duration-[var(--duration-fast)] ease-[var(--ease-out)]",                      done ? "bg-(--color-ink)/25" : "bg-(--color-rule)",                    )}                  />                )}              </li>            );          })}        </ol>      </div>
      {/* Panel — direction-aware page slide + height resize */}      <div        className="t-resize overflow-hidden"        style={          {            height: panelHeight,            "--resize-dur": "var(--duration-fast)",          } as React.CSSProperties        }      >        <div          className="t-page-slide relative"          data-page={pageAttr}          style={            {              "--page-exit-enabled": hasTransitioned ? "1" : "0",            } as React.CSSProperties          }        >          <section className="t-page" data-page-id="1">            <StepPanel step={page1Step} />          </section>          <section className="t-page" data-page-id="2">            <StepPanel step={page2Step} />          </section>          {/* Height shim: absolute pages don't contribute; mirror active content */}          <div            ref={shimRef}            className="invisible pointer-events-none"            aria-hidden          >            <StepPanel step={current} />          </div>        </div>      </div>
      {/* Actions */}      <div className="flex items-center justify-between gap-3 border-t border-(--color-rule) px-5 py-4 sm:px-6">        <button          type="button"          disabled={isFirst}          onClick={() => setStep(clamped - 1)}          className={cn(            "min-h-9 rounded-md px-3 text-sm font-medium",            "text-(--color-ink-2) outline-none",            "transition-[opacity,transform,color] duration-100 ease-[var(--ease-smooth-out)]",            "hover:text-(--color-ink) active:scale-[0.97]",            "focus-visible:ring-2 focus-visible:ring-(--color-focus)",            "disabled:pointer-events-none disabled:opacity-30",          )}        >          {backLabel}        </button>        <button          type="button"          onClick={() => {            if (isLast) onComplete?.();            else setStep(clamped + 1);          }}          className={cn(            "inline-flex min-h-9 min-w-24 items-center justify-center rounded-full px-4 text-sm font-medium",            "bg-(--color-ink) text-(--color-paper) outline-none",            "transition-transform duration-100 ease-[var(--ease-smooth-out)]",            "active:scale-[0.97]",            "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",          )}        >          <span ref={primaryLabelRef} className="t-text-swap whitespace-nowrap">            {primaryLabel}          </span>        </button>      </div>    </div>  );}
function StepPanel({ step }: { step: StepWizardStep }) {  // Padding lives on the panel (not .t-page-slide) because absolute  // .t-page pages use inset:0 and ignore parent padding.  return (    <div className="px-5 py-5 sm:px-6 sm:py-6">      <h3 className="text-balance text-base font-semibold leading-snug text-(--color-ink)">        {step.title}      </h3>      {step.description ? (        <p className="mt-2 text-sm leading-relaxed text-(--color-ink-muted)">          {step.description}        </p>      ) : null}      <div className="mt-5">{step.content}</div>    </div>  );}

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
import { StepWizard } from "@/components/ui/step-wizard";
<StepWizard  steps={[    { id: "a", title: "Project", content: <ProjectForm /> },    { id: "b", title: "Team", content: <TeamForm /> },    { id: "c", title: "Review", content: <Review /> },  ]}  onComplete={() => createProject()}/>

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

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).
  4. Prefer celebration (success check) in the step content on complete, not a second modal.