"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AlertCircle, ArrowLeft, ArrowRight, Check, PartyPopper } from "lucide-react";
import { cn } from "@/lib/utils";
export type WizardStep = {
id: string;
title: string;
description?: string;
optional?: boolean;
/** Content of the step. */
content: React.ReactNode;
/** Return an error message to block "Next", or null when the step is valid. */
validate?: () => string | null;
};
export interface StepperWizardProps {
steps: WizardStep[];
orientation?: "horizontal" | "vertical";
/** When true, future steps can't be jumped to before the current one validates. */
linear?: boolean;
defaultStep?: number;
onStepChange?: (index: number) => void;
onComplete?: () => void;
finishLabel?: string;
/** Rendered after completion. */
success?: React.ReactNode;
className?: string;
}
type StepState = "complete" | "current" | "error" | "upcoming" | "skipped";
export function StepperWizard({
steps,
orientation = "horizontal",
linear = true,
defaultStep = 0,
onStepChange,
onComplete,
finishLabel = "Finish",
success,
className,
}: StepperWizardProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const [index, setIndex] = React.useState(defaultStep);
const [dir, setDir] = React.useState(1);
const [done, setDone] = React.useState<Set<number>>(() => new Set());
const [skipped, setSkipped] = React.useState<Set<number>>(() => new Set());
const [error, setError] = React.useState<{ step: number; msg: string; n: number } | null>(null);
const [finished, setFinished] = React.useState(false);
const headingRef = React.useRef<HTMLHeadingElement>(null);
const moved = React.useRef(false);
const step = steps[index];
const last = index === steps.length - 1;
const vertical = orientation === "vertical";
React.useEffect(() => {
if (!moved.current) return;
headingRef.current?.focus({ preventScroll: true });
}, [index, finished]);
const goTo = (i: number) => {
moved.current = true;
setDir(i > index ? 1 : -1);
setIndex(i);
setError(null);
onStepChange?.(i);
};
const validateCurrent = () => {
const msg = step.validate?.() ?? null;
if (msg) {
setError((e) => ({ step: index, msg, n: (e?.n ?? 0) + 1 }));
return false;
}
return true;
};
const next = () => {
if (!validateCurrent()) return;
setDone((d) => new Set(d).add(index));
setSkipped((s) => {
const n = new Set(s);
n.delete(index);
return n;
});
if (last) {
moved.current = true;
setFinished(true);
onComplete?.();
} else goTo(index + 1);
};
const skip = () => {
setSkipped((s) => new Set(s).add(index));
goTo(index + 1);
};
const stateOf = (i: number): StepState => {
if (error?.step === i) return "error";
if (i === index && !finished) return "current";
if (skipped.has(i)) return "skipped";
if (done.has(i)) return "complete";
return "upcoming";
};
const reachable = (i: number) =>
!finished && (!linear || i <= index || Array.from({ length: i }, (_, k) => k).every((k) => done.has(k) || skipped.has(k)));
const progress = finished ? 1 : index / Math.max(1, steps.length - 1);
return (
<div className={cn("w-full max-w-3xl rounded-2xl border bg-card text-card-foreground shadow-sm", vertical && "sm:flex", className)}>
{/* step list */}
<nav aria-label="Progress" className={cn("border-b p-4 sm:p-5", vertical && "sm:w-60 sm:shrink-0 sm:border-b-0 sm:border-r")}>
<ol className={cn("relative flex", vertical ? "gap-1 sm:flex-col" : "items-start")}>
{steps.map((s, i) => {
const st = stateOf(i);
const canGo = reachable(i) && i !== index;
const filled = finished || i < index;
return (
<li key={s.id} className={cn("relative flex min-w-0", vertical ? "flex-1 sm:flex-none" : "flex-1")}>
{/* connector */}
{i < steps.length - 1 && (
<span
aria-hidden
className={cn(
"absolute overflow-hidden rounded-full bg-border",
vertical
? "left-[calc(50%+18px)] right-[calc(-50%+18px)] top-[17px] h-0.5 sm:left-[17px] sm:right-auto sm:top-[42px] sm:h-[calc(100%-38px)] sm:w-0.5"
: "left-[calc(50%+22px)] right-[calc(-50%+22px)] top-[17px] h-0.5",
)}
>
<motion.span
className={cn("absolute inset-0 bg-primary", vertical ? "origin-left sm:origin-top" : "origin-left")}
initial={false}
animate={vertical ? { scaleX: filled ? 1 : 0, scaleY: filled ? 1 : 0 } : { scaleX: filled ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.45, ease: [0.22, 1, 0.36, 1] }}
/>
</span>
)}
<button
type="button"
onClick={() => canGo && goTo(i)}
aria-disabled={!canGo || undefined}
aria-current={i === index && !finished ? "step" : undefined}
className={cn(
"group relative flex w-full min-w-0 rounded-xl p-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
vertical ? "flex-col items-center gap-2 sm:flex-row sm:items-start sm:gap-3 sm:p-1.5" : "flex-col items-center gap-2 text-center",
canGo ? "cursor-pointer" : "cursor-default",
)}
>
<StepDot state={st} n={i + 1} reduce={!!reduce} />
<span className={cn("min-w-0", vertical ? "hidden sm:block sm:pt-1.5" : "hidden sm:block")}>
<span className={cn("block truncate text-sm font-medium", st === "upcoming" ? "text-muted-foreground" : "text-foreground", st === "error" && "text-destructive")}>
{s.title}
</span>
<span className="block truncate text-xs text-muted-foreground">
{s.optional ? (skipped.has(i) ? "Skipped" : "Optional") : s.description}
</span>
</span>
<span className="sr-only">
{st === "complete" ? ", completed" : st === "error" ? ", has errors" : st === "skipped" ? ", skipped" : st === "current" ? ", current step" : ""}
</span>
</button>
</li>
);
})}
</ol>
{/* mobile caption */}
<div className="mt-3 flex items-center justify-between text-xs sm:hidden">
<span className="font-medium">{finished ? "All done" : step.title}</span>
<span className="text-muted-foreground tabular-nums">
{finished ? steps.length : index + 1} / {steps.length}
</span>
</div>
</nav>
{/* panel */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="h-0.5 bg-muted sm:hidden">
<motion.div className="h-full origin-left bg-primary" animate={{ scaleX: progress }} transition={{ type: "spring", stiffness: 200, damping: 30 }} />
</div>
<AutoHeight>
<AnimatePresence mode="popLayout" initial={false} custom={dir}>
<motion.div
key={finished ? "done" : step.id}
custom={dir}
variants={{
enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 36, filter: reduce ? "none" : "blur(4px)" }),
center: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -36, filter: reduce ? "none" : "blur(4px)" }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 380, damping: 36 }}
className="p-5 sm:p-6"
>
{finished ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<motion.span
initial={reduce ? false : { scale: 0, rotate: -40 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 360, damping: 14 }}
className="grid size-14 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/30"
>
<PartyPopper className="size-6" aria-hidden />
</motion.span>
<h2 ref={headingRef} tabIndex={-1} className="text-lg font-semibold outline-none">
You’re all set
</h2>
{success ?? <p className="max-w-sm text-sm text-muted-foreground">Everything is saved. You can change these settings any time.</p>}
<button
type="button"
onClick={() => {
setFinished(false);
setDone(new Set());
setSkipped(new Set());
goTo(0);
}}
className="mt-1 text-sm font-medium text-primary underline-offset-4 outline-none hover:underline focus-visible:underline"
>
Start over
</button>
</div>
) : (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
next();
}}
aria-labelledby={`${uid}-h`}
>
<div className="mb-4">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Step {index + 1} of {steps.length}
{step.optional && " · Optional"}
</p>
<h2 ref={headingRef} id={`${uid}-h`} tabIndex={-1} className="mt-1 text-lg font-semibold tracking-tight outline-none">
{step.title}
</h2>
{step.description && <p className="text-sm text-muted-foreground">{step.description}</p>}
</div>
<div>{step.content}</div>
<AnimatePresence>
{error?.step === index && (
<motion.p
key={error.n}
role="alert"
initial={{ opacity: 0, y: -4, x: 0 }}
animate={reduce ? { opacity: 1, y: 0 } : { opacity: 1, y: 0, x: [0, -6, 6, -3, 3, 0] }}
exit={{ opacity: 0 }}
transition={{ duration: 0.35 }}
className="mt-4 flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
<AlertCircle className="size-4 shrink-0" aria-hidden /> {error.msg}
</motion.p>
)}
</AnimatePresence>
<div className="mt-6 flex items-center gap-2">
<button
type="button"
onClick={() => goTo(index - 1)}
disabled={index === 0}
className="inline-flex h-9 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40 disabled:invisible"
>
<ArrowLeft className="size-4" aria-hidden /> Back
</button>
<div className="ml-auto flex items-center gap-2">
{step.optional && !last && (
<button
type="button"
onClick={skip}
className="h-9 rounded-lg px-3 text-sm font-medium text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
>
Skip
</button>
)}
<button
type="submit"
className="group inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-card active:scale-[0.98]"
>
{last ? finishLabel : "Continue"}
{last ? <Check className="size-4" aria-hidden /> : <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />}
</button>
</div>
</div>
</form>
)}
</motion.div>
</AnimatePresence>
</AutoHeight>
</div>
</div>
);
}
function StepDot({ state, n, reduce }: { state: StepState; n: number; reduce: boolean }) {
return (
<span
className={cn(
"relative z-10 grid size-9 shrink-0 place-items-center rounded-full border-2 text-sm font-semibold tabular-nums transition-colors duration-300",
state === "complete" && "border-primary bg-primary text-primary-foreground",
state === "current" && "border-primary bg-card text-primary",
state === "error" && "border-destructive bg-destructive/10 text-destructive",
state === "upcoming" && "border-border bg-card text-muted-foreground",
state === "skipped" && "border-dashed border-muted-foreground/50 bg-card text-muted-foreground",
)}
>
{state === "current" && !reduce && (
<motion.span
aria-hidden
className="absolute -inset-1 rounded-full border-2 border-primary/30"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 18 }}
/>
)}
{state === "complete" ? (
<svg viewBox="0 0 24 24" className="size-4" aria-hidden>
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, ease: "easeOut" }}
/>
</svg>
) : state === "error" ? (
"!"
) : (
n
)}
</span>
);
}
/** Animates its height to fit the content. */
function AutoHeight({ children }: { children: React.ReactNode }) {
const ref = React.useRef<HTMLDivElement>(null);
const [h, setH] = React.useState<number | "auto">("auto");
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(() => setH(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<motion.div animate={{ height: h }} transition={{ type: "spring", stiffness: 320, damping: 34 }} className="relative overflow-hidden">
<div ref={ref}>{children}</div>
</motion.div>
);
}