Fazekit

Code

"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>
  );
}

More in Navigation

View all →