Overview

A step indicator for checkouts and onboarding. Connectors fill on a spring as steps complete, finished steps draw a check, and a soft halo breathes around the current step and slides along when it moves. Steps can carry a short description, the layout can be horizontal or vertical, and finished steps are clickable so people can go back. Pass steps.length as the value to mark everything done.


Installation

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/step-progress.tsx
tsx
"use client";
import { useId } from "react";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import { cn } from "@/lib/utils";
export type StepProgressStep = string | { label: string; description?: string };
export type StepProgressProps = {  steps: StepProgressStep[];  /** Index of the current step. Pass `steps.length` to mark everything done. */  value: number;  /** Makes finished steps (and the current one) clickable. */  onChange?: (index: number) => void;  /** Let people jump ahead to steps they haven't reached yet. */  allowSkip?: boolean;  orientation?: "horizontal" | "vertical";  className?: string;};
const EASE_OUT = [0.23, 1, 0.32, 1] as const;const FILL = { type: "spring" as const, bounce: 0, duration: 0.6 };
function Node({ index, done, active, reduce, uid }: { index: number; done: boolean; active: boolean; reduce: boolean; uid: string }) {  return (    <span      className={cn(        "relative grid size-7 shrink-0 place-items-center rounded-full text-[12px] font-semibold tabular-nums transition-[background-color,color,box-shadow] duration-300",        done || active          ? "bg-(--color-ink) text-(--color-paper)"          : "bg-(--color-paper) text-(--color-neutral) shadow-[inset_0_0_0_1.5px_var(--color-rule)]",      )}    >      {active && !reduce ? (        <motion.span          layoutId={`${uid}-halo`}          aria-hidden          className="absolute -inset-1.5 rounded-full shadow-[0_0_0_1.5px_var(--color-ink)]"          initial={false}          animate={{ opacity: [0.35, 0.12, 0.35] }}          transition={{ opacity: { duration: 2, repeat: Infinity, ease: "easeInOut" }, layout: { type: "spring", bounce: 0.2, duration: 0.5 } }}        />      ) : null}      <AnimatePresence initial={false} mode="popLayout">        {done ? (          <motion.svg            key="check"            viewBox="0 0 16 16"            className="size-3.5"            fill="none"            stroke="currentColor"            strokeWidth="2.4"            strokeLinecap="round"            strokeLinejoin="round"            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}            animate={{ opacity: 1, scale: 1 }}            exit={{ opacity: 0, scale: 0.5 }}            transition={{ type: "spring", bounce: 0.4, duration: 0.4 }}          >            <motion.path d="M3.5 8.5l3 3 6-7" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.08 }} />          </motion.svg>        ) : (          <motion.span            key="n"            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}            animate={{ opacity: 1, scale: 1 }}            exit={{ opacity: 0, scale: 0.5 }}            transition={{ duration: 0.2 }}          >            {index + 1}          </motion.span>        )}      </AnimatePresence>    </span>  );}
export function StepProgress({ steps, value, onChange, allowSkip = false, orientation = "horizontal", className }: StepProgressProps) {  const reduce = !!useReducedMotion();  const uid = useId();  if (steps.length === 0) return null;  const items = steps.map((s) => (typeof s === "string" ? { label: s } : s));  const current = Math.max(0, Math.min(items.length, value));  const allDone = current >= items.length;  const vertical = orientation === "vertical";  const status = allDone ? "All steps complete" : `Step ${current + 1} of ${items.length}: ${items[current].label}`;
  return (    <div className={cn("w-full", className)}>      <div role="list" aria-label="Progress" className={cn("flex", vertical ? "flex-col" : "items-start")}>        {items.map((step, index) => {          const done = index < current;          const active = index === current;          const clickable = !!onChange && (allowSkip || index <= current) && !active;          const last = index === items.length - 1;          // Connector to the next step fills when this step is done.          const segmentFill = index < current ? 1 : 0;
          return (            <div              role="listitem"              key={`${step.label}-${index}`}              className={cn("relative flex", vertical ? "gap-3.5" : "min-w-0 flex-1 flex-col items-center")}            >              {!last ? (                <span                  aria-hidden                  className={cn(                    "absolute overflow-hidden rounded-full bg-(--color-rule)",                    vertical ? "top-9 bottom-1 left-[13px] w-[2px]" : "top-[13px] right-[calc(-50%+18px)] left-[calc(50%+18px)] h-[2px]",                  )}                >                  <motion.span                    className={cn("absolute inset-0 bg-(--color-ink)", vertical ? "origin-top" : "origin-left")}                    initial={false}                    animate={vertical ? { scaleY: segmentFill } : { scaleX: segmentFill }}                    transition={reduce ? { duration: 0 } : FILL}                  />                </span>              ) : null}
              <button                type="button"                aria-current={active ? "step" : undefined}                aria-label={`${step.label}${active ? ", current step" : done ? ", completed" : ""}`}                disabled={!clickable}                onClick={() => onChange?.(index)}                className={cn(                  "group relative z-1 flex rounded-[10px] text-left outline-none",                  "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",                  vertical ? "items-start gap-3.5 pb-7" : "flex-col items-center gap-2 px-1 text-center",                  vertical && last && "pb-0",                  clickable ? "cursor-pointer" : "cursor-default",                )}              >                <span className={cn("transition-transform duration-200", clickable && "group-hover:scale-110 group-active:scale-95")}>                  <Node index={index} done={done} active={active} reduce={reduce} uid={uid} />                </span>                <span className={cn("min-w-0", vertical && "pt-[3px]")}>                  <span                    className={cn(                      "block text-[12.5px] leading-tight font-medium transition-colors duration-300",                      active ? "text-(--color-ink)" : done ? "text-(--color-ink-2)" : "text-(--color-neutral)",                    )}                  >                    {step.label}                  </span>                  {step.description ? (                    <motion.span                      initial={false}                      animate={{ opacity: active || vertical ? 1 : 0.7 }}                      className={cn("mt-0.5 block text-[12px] leading-snug text-(--color-neutral)", !vertical && "hidden sm:block")}                    >                      {step.description}                    </motion.span>                  ) : null}                </span>              </button>            </div>          );        })}      </div>      <div className="sr-only" aria-live="polite">        {status}      </div>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { StepProgress } from "@/components/ui/step-progress";
const steps = [  { label: "Details", description: "Name and contact" },  { label: "Delivery", description: "Address and slot" },  { label: "Payment" },  { label: "Review" },];
<StepProgress steps={steps} value={step} onChange={setStep} /><StepProgress steps={steps} value={step} orientation="vertical" />

Props

PropTypeRequiredDefaultDescription
steps(string | { label, description? })[]Yes—Step labels, optionally with a description.
valuenumberYes—Index of the current step. steps.length marks every step done.
onChange(index: number) => voidNo—Makes finished steps clickable.
allowSkipbooleanNofalseAlso allow clicking steps that haven't been reached.
orientation"horizontal" | "vertical"No"horizontal"Layout. Vertical always shows descriptions; horizontal shows them from the sm breakpoint.
classNamestringNo—Classes for the wrapper.

Best Practices

  1. Keep labels to one or two words; put detail in description.
  2. Use horizontal for three to five steps and vertical for longer flows or narrow sidebars.
  3. Only enable allowSkip when steps don't depend on each other.
  4. Step changes are announced to screen readers; reduced motion fills connectors instantly.