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 motionAdd 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:
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
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| steps | (string | { label, description? })[] | Yes | — | Step labels, optionally with a description. |
| value | number | Yes | — | Index of the current step. steps.length marks every step done. |
| onChange | (index: number) => void | No | — | Makes finished steps clickable. |
| allowSkip | boolean | No | false | Also 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. |
| className | string | No | — | Classes for the wrapper. |
Best Practices
- Keep labels to one or two words; put detail in
description. - Use horizontal for three to five steps and vertical for longer flows or narrow sidebars.
- Only enable
allowSkipwhen steps don't depend on each other. - Step changes are announced to screen readers; reduced motion fills connectors instantly.