Wizard Nav
Navigation

Wizard Nav

Interactive multi-step navigation — visited steps are clickable, connectors fill and retract interruptibly, indicators pop-swap between number, check, and error, with aria-current and sr-only state text.

Install

npx shadcn@latest add @paragon/wizard-nav

wizard-nav.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils";

export interface WizardNavStep {
  id: string;
  label: string;
  description?: string;
  optional?: boolean;
}

export interface WizardNavProps
  extends Omit<React.ComponentProps<"nav">, "onChange"> {
  steps: WizardNavStep[];
  /** Current step id (controlled). */
  value?: string;
  defaultValue?: string;
  onValueChange?: (id: string) => void;
  /** Step ids currently failing validation — flagged and announced. */
  errorSteps?: string[];
  /** Which steps can be jumped to directly: visited (default), all, none. */
  navigable?: "visited" | "all" | "none";
  /** Disables the fill and icon motion. */
  static?: boolean;
}

/**
 * The navigation shell for a multi-step flow — where `stepper` displays
 * progress, WizardNav drives it. Visited steps are real buttons you can jump
 * back to, future steps stay disabled until reached, connectors fill (and
 * retract on backtracking) with an interruptible transform, and each
 * indicator pop-swaps between number, check, and error mark. The current
 * step carries aria-current="step"; error steps announce via sr-only text,
 * not color alone.
 */
export function WizardNav({
  steps,
  value: valueProp,
  defaultValue,
  onValueChange,
  errorSteps = [],
  navigable = "visited",
  static: isStatic = false,
  className,
  ...props
}: WizardNavProps) {
  const reducedMotion = useReducedMotion();
  const [internalValue, setInternalValue] = React.useState(
    defaultValue ?? steps[0]?.id,
  );
  const value = valueProp ?? internalValue;
  const currentIndex = Math.max(
    0,
    steps.findIndex((step) => step.id === value),
  );

  const select = (id: string) => {
    setInternalValue(id);
    onValueChange?.(id);
  };

  const swap = { type: "spring", duration: 0.3, bounce: 0 } as const;

  return (
    <nav
      data-slot="wizard-nav"
      aria-label="Progress"
      className={cn("w-full", className)}
      {...props}
    >
      <ol className="flex w-full">
        {steps.map((step, index) => {
          const isComplete = index < currentIndex;
          const isCurrent = index === currentIndex;
          const hasError = errorSteps.includes(step.id);
          const isLast = index === steps.length - 1;
          const clickable =
            navigable === "all" ||
            (navigable === "visited" && index <= currentIndex);

          const iconKey = hasError ? "error" : isComplete ? "check" : "number";

          return (
            <li
              key={step.id}
              className={cn("relative min-w-0", !isLast && "flex-1")}
            >
              <div className="flex items-center">
                <button
                  type="button"
                  disabled={!clickable}
                  aria-current={isCurrent ? "step" : undefined}
                  onClick={() => select(step.id)}
                  className={cn(
                    "group relative flex flex-col items-start rounded-lg text-left outline-none",
                    "after:absolute after:top-1/2 after:left-1/2 after:h-full after:min-h-10 after:w-full after:min-w-10 after:-translate-x-1/2 after:-translate-y-1/2",
                    "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                    !clickable && "cursor-default",
                  )}
                >
                  <span
                    aria-hidden
                    className={cn(
                      "flex size-7 items-center justify-center rounded-full text-xs font-semibold tabular-nums",
                      "transition-[background-color,color,box-shadow,scale] duration-200 ease-[var(--ease-out)]",
                      hasError
                        ? "bg-destructive/10 text-destructive shadow-[0_0_0_1.5px_var(--color-destructive)]"
                        : isComplete
                          ? "bg-primary text-primary-foreground"
                          : isCurrent
                            ? "bg-background text-primary shadow-[0_0_0_1.5px_var(--color-primary)]"
                            : "bg-card text-muted-foreground shadow-border",
                      clickable &&
                        !isStatic &&
                        "group-hover:scale-105 group-active:scale-95 motion-reduce:group-hover:scale-100",
                    )}
                  >
                    {isStatic ? (
                      hasError ? (
                        <TriangleAlert className="size-3.5" />
                      ) : isComplete ? (
                        <Check className="size-3.5" />
                      ) : (
                        index + 1
                      )
                    ) : (
                      <AnimatePresence mode="popLayout" initial={false}>
                        <motion.span
                          key={iconKey}
                          initial={
                            reducedMotion
                              ? { opacity: 0 }
                              : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
                          }
                          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                          exit={
                            reducedMotion
                              ? { opacity: 0 }
                              : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
                          }
                          transition={swap}
                          className="flex items-center justify-center"
                        >
                          {hasError ? (
                            <TriangleAlert className="size-3.5" />
                          ) : isComplete ? (
                            <Check className="size-3.5" />
                          ) : (
                            index + 1
                          )}
                        </motion.span>
                      </AnimatePresence>
                    )}
                  </span>

                  <span className="mt-2 flex w-full min-w-0 flex-col pr-3">
                    <span
                      className={cn(
                        "truncate text-[13px] leading-4 font-medium transition-colors duration-200",
                        hasError
                          ? "text-destructive"
                          : isCurrent || isComplete
                            ? "text-foreground"
                            : "text-muted-foreground",
                      )}
                    >
                      {step.label}
                      {step.optional && (
                        <span className="ml-1 font-normal text-muted-foreground">
                          · Optional
                        </span>
                      )}
                    </span>
                    {step.description && (
                      <span className="mt-0.5 truncate text-[11px] text-muted-foreground">
                        {step.description}
                      </span>
                    )}
                    <span className="sr-only">
                      {hasError
                        ? " (has errors)"
                        : isComplete
                          ? " (completed)"
                          : isCurrent
                            ? ""
                            : " (not started)"}
                    </span>
                  </span>
                </button>

              </div>

              {/* Connector spans from the CURRENT circle's right edge to the
                  NEXT circle's left edge (the li's right boundary — lis are
                  equal-width, so the next circle sits at 100%). Positioned
                  absolutely against the circle centre (size-7 ⇒ 14px) so its
                  length is independent of the label width and it fully bridges
                  the two circles instead of stopping short. */}
              {!isLast && (
                <span
                  aria-hidden
                  className="absolute top-[0.875rem] left-[calc(1.75rem+0.5rem)] right-[0.5rem] h-0.5 -translate-y-1/2 overflow-hidden rounded-full bg-border"
                >
                  <span
                    className={cn(
                      "absolute inset-0 origin-left rounded-full bg-primary",
                      "transition-transform duration-300 ease-[var(--ease-in-out)] motion-reduce:transition-none",
                      isComplete ? "scale-x-100" : "scale-x-0",
                    )}
                  />
                </span>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}