"use client";
import * as React from "react";
export type StepStatus = "complete" | "current" | "upcoming" | "error";
export interface StepIndicatorStep {
/** Stable key. Defaults to the index. */
id?: string;
title: string;
description?: React.ReactNode;
/** Overrides the status derived from `current` (e.g. "error" for a step that failed validation). */
status?: StepStatus;
/** Marks the step as optional ("Optional" label + screen reader text). */
optional?: boolean;
}
export interface StepIndicatorProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
steps: StepIndicatorStep[];
/** Zero-based index of the current step. Earlier steps are complete, later ones upcoming. */
current: number;
/** Lay the steps out in a row or a column. */
orientation?: "horizontal" | "vertical";
/** Makes steps clickable. Receives the index of the clicked step. */
onStepClick?: (index: number) => void;
/** Linear mode: only completed steps (and the current one) can be clicked. Otherwise every step can. */
linear?: boolean;
/** Show each step's description. */
showDescriptions?: boolean;
/** Time each connector takes to fill, in ms. Several connectors fill one after another. */
fillDuration?: number;
/** Horizontal only: below this container width the list collapses to "Step 2 of 5 · Title". */
collapseBelow?: "none" | "24rem" | "32rem" | "42rem";
/** Fill, current ring and focus colour. */
accent?: string;
/** Accessible name of the navigation landmark. */
label?: string;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
// Container-query class pairs. Written out in full so Tailwind can see every class.
const COLLAPSE: Record<NonNullable<StepIndicatorProps["collapseBelow"]>, { list: string; summary: string }> = {
none: { list: "", summary: "hidden" },
"24rem": { list: "@max-sm:hidden", summary: "hidden @max-sm:block" },
"32rem": { list: "@max-lg:hidden", summary: "hidden @max-lg:block" },
"42rem": { list: "@max-2xl:hidden", summary: "hidden @max-2xl:block" },
};
const STATUS_TEXT: Record<StepStatus, string> = {
complete: "Completed",
current: "Current step",
upcoming: "Not started",
error: "Needs attention",
};
/**
* A wizard progress indicator: an ordered list of steps whose connectors fill as you advance.
* Completed steps can be revisited through `onStepClick`; the current step carries aria-current="step".
*/
export function StepIndicator({
steps,
current,
orientation = "horizontal",
onStepClick,
linear = true,
showDescriptions = true,
fillDuration = 450,
collapseBelow = "32rem",
accent = "#FF7448",
label = "Progress",
className = "",
style,
...props
}: StepIndicatorProps) {
const reduceMotion = usePrefersReducedMotion();
const total = steps.length;
const active = Math.max(0, Math.min(total - 1, Math.round(current)));
// Remember where the last move started, so connectors fill (or drain) one after another.
const [shown, setShown] = React.useState(active);
const [from, setFrom] = React.useState(active);
if (active !== shown) {
setFrom(shown);
setShown(active);
}
const duration = reduceMotion ? 0 : fillDuration;
const statusOf = (index: number): StepStatus =>
steps[index]?.status ?? (index < active ? "complete" : index === active ? "current" : "upcoming");
const canClick = (index: number) => !!onStepClick && (!linear || index <= active);
// Delay for the connector after step `index`: forward moves fill left-to-right, backward moves drain right-to-left.
const connectorDelay = (index: number) => {
if (active > from && index >= from && index < active) return (index - from) * duration;
if (active < from && index >= active && index < from) return (from - 1 - index) * duration;
return 0;
};
const vertical = orientation === "vertical";
const collapse = vertical ? COLLAPSE.none : COLLAPSE[collapseBelow];
const currentStep = steps[active];
return (
<nav
aria-label={label}
className={`@container w-full text-zinc-900 dark:text-zinc-100 ${className}`}
style={{ ["--si-accent" as string]: accent, ...style }}
{...props}
>
<style>{`@keyframes si-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}`}</style>
<ol className={`${vertical ? "flex flex-col" : "flex items-start"} ${collapse.list}`}>
{steps.map((step, index) => {
const status = statusOf(index);
const last = index === total - 1;
const filled = index < active;
const clickable = canClick(index);
const Tag = clickable ? "button" : "div";
const marker = <StepMarker status={status} index={index} duration={duration} />;
const connector = !last && (
<span
aria-hidden="true"
className={`relative block overflow-hidden rounded-full bg-zinc-900/10 dark:bg-white/10 ${
vertical ? "my-1.5 w-[2px] flex-1" : "mx-2 h-[2px] flex-1"
}`}
>
<span
className={`absolute inset-0 bg-[var(--si-accent)] transition-transform ease-[cubic-bezier(0.65,0,0.35,1)] ${
vertical ? "origin-top" : "origin-left"
}`}
style={{
transform: vertical ? `scaleY(${filled ? 1 : 0})` : `scaleX(${filled ? 1 : 0})`,
transitionDuration: `${duration}ms`,
transitionDelay: `${connectorDelay(index)}ms`,
}}
/>
</span>
);
const text = (
<span className={`flex min-w-0 flex-col ${vertical ? "pt-1" : "mt-2.5 pr-3"}`}>
<span
className={`text-[13px] font-medium leading-tight tracking-tight transition-colors ${
status === "upcoming"
? "text-zinc-500 dark:text-zinc-400"
: status === "error"
? "text-red-600 dark:text-red-400"
: "text-zinc-900 dark:text-zinc-50"
}`}
>
{step.title}
</span>
{step.optional && (
<span className="mt-1 font-mono text-[9px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500">Optional</span>
)}
{showDescriptions && step.description && (
<span className="mt-1 text-[12px] leading-snug text-zinc-500 dark:text-zinc-400">{step.description}</span>
)}
<span className="sr-only">
, {STATUS_TEXT[status]}
{step.optional ? ", optional" : ""}
</span>
</span>
);
return (
<li
key={step.id ?? index}
className={vertical ? "flex gap-3" : `flex min-w-0 flex-col ${last ? "shrink-0" : "flex-1"}`}
>
{vertical ? (
<>
<span className="flex flex-col items-center">
<Tag
{...(clickable ? { type: "button" as const, onClick: () => onStepClick?.(index) } : {})}
aria-current={index === active ? "step" : undefined}
aria-label={clickable ? `${step.title}, ${STATUS_TEXT[status]}${step.optional ? ", optional" : ""}` : undefined}
className={`group/step rounded-full ${clickable ? "cursor-pointer outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--si-accent)]" : ""}`}
>
{marker}
</Tag>
{connector}
</span>
<span className={last ? "" : "pb-6"}>{text}</span>
</>
) : (
<Tag
{...(clickable ? { type: "button" as const, onClick: () => onStepClick?.(index) } : {})}
aria-current={index === active ? "step" : undefined}
className={`group/step flex min-w-0 flex-col items-stretch rounded-lg text-left ${
clickable
? "cursor-pointer outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--si-accent)]"
: ""
}`}
>
<span className="flex w-full items-center">
{marker}
{connector}
</span>
{text}
</Tag>
)}
</li>
);
})}
</ol>
{/* Collapsed summary for narrow containers (horizontal only). */}
{currentStep && (
<div className={collapse.summary}>
<p className="flex min-w-0 items-baseline gap-2 text-[13px]">
<span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
Step {active + 1} of {total}
</span>
<span aria-hidden="true" className="text-zinc-300 dark:text-zinc-600">
·
</span>
<span
aria-current="step"
className={`truncate font-medium tracking-tight ${statusOf(active) === "error" ? "text-red-600 dark:text-red-400" : ""}`}
>
{currentStep.title}
</span>
</p>
<div aria-hidden="true" className="mt-2.5 flex gap-1">
{steps.map((step, index) => {
const status = statusOf(index);
return (
<span key={step.id ?? index} className="relative h-1 flex-1 overflow-hidden rounded-full bg-zinc-900/10 dark:bg-white/10">
<span
className={`absolute inset-0 origin-left transition-transform ease-[cubic-bezier(0.65,0,0.35,1)] ${
status === "error" ? "bg-red-500" : "bg-[var(--si-accent)]"
}`}
style={{
transform: `scaleX(${status === "complete" || status === "error" ? 1 : status === "current" ? 0.5 : 0})`,
transitionDuration: `${duration}ms`,
}}
/>
</span>
);
})}
</div>
</div>
)}
</nav>
);
}
function StepMarker({ status, index, duration }: { status: StepStatus; index: number; duration: number }) {
const base =
"relative flex size-7 shrink-0 items-center justify-center rounded-full border font-mono text-[11px] tabular-nums transition-[background-color,border-color,color,box-shadow] duration-300";
const tone = {
complete: "border-[var(--si-accent)] bg-[var(--si-accent)] text-zinc-950 group-hover/step:brightness-95",
current:
"border-[var(--si-accent)] bg-white text-zinc-900 shadow-[0_0_0_4px_color-mix(in_oklab,var(--si-accent)_22%,transparent)] dark:bg-zinc-950 dark:text-zinc-50",
upcoming:
"border-zinc-900/15 bg-white text-zinc-500 group-hover/step:border-zinc-900/30 dark:border-white/15 dark:bg-zinc-950 dark:text-zinc-400 dark:group-hover/step:border-white/30",
error: "border-red-500 bg-red-500 text-white shadow-[0_0_0_4px_rgba(239,68,68,0.18)]",
}[status];
return (
<span aria-hidden="true" className={`${base} ${tone}`}>
{status === "complete" ? (
<svg viewBox="0 0 16 16" className="size-3.5" fill="none">
<path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
// Draws the check in once the step completes.
className="[stroke-dasharray:1] [animation:si-draw_var(--si-draw)_ease-out_both]"
style={{ ["--si-draw" as string]: `${Math.max(1, duration)}ms` }}
/>
</svg>
) : status === "error" ? (
<svg viewBox="0 0 16 16" className="size-3.5" fill="none">
<path d="M8 4v5" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<circle cx="8" cy="11.75" r="1.1" fill="currentColor" />
</svg>
) : (
<span className={status === "current" ? "font-semibold" : ""}>{String(index + 1).padStart(2, "0")}</span>
)}
</span>
);
}