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.
npx shadcn@latest add @paragon/wizard-nav"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>
);
}