A horizontal or vertical multi-step progress indicator whose connectors fill with the accent color and whose step numbers morph into drawn check marks as you advance. Includes Back/Next demo controls and optional click-to-revisit for completed steps.
Preview
- Account (completed)Email and password
- Profile (current step)Name and avatar
- WorkspaceInvite your team
- LaunchReview and go live
Step 2 of 4: Profile
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add stepperWrites components/ui/stepper.tsx and components/styles/stepper.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Stepper } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Stepper />6 );7}Props
Accessibility
Steps are an ordered list labelled 'Progress'; the current step has aria-current="step" and each title carries visually hidden '(completed)' or '(current step)' text. Completed steps become native buttons ('Go back to step N: Title') when clickable. Back/Next are native buttons (Back is disabled on the first step) and a polite live region announces 'Step X of Y: Title'. Connector lines and icons are aria-hidden. Reduced motion removes the fill, scale and path-draw animations.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/stepper.module.css";6 7export interface StepperStep {8 /** Step title */9 title: string;10 /** Optional supporting line */11 description?: string;12}13 14export interface StepperProps {15 /** Steps to render */16 steps?: StepperStep[];17 /** Controlled active step index (0-based; steps.length means all complete) */18 activeStep?: number;19 /** Initial active step when uncontrolled */20 defaultStep?: number;21 /** Called with the next active step index */22 onStepChange?: (step: number) => void;23 /** Layout direction */24 orientation?: "horizontal" | "vertical";25 /** Size preset */26 size?: "sm" | "md" | "lg";27 /** Accent color of completed steps and the connector fill */28 accentColor?: string;29 /** Show step descriptions */30 showDescriptions?: boolean;31 /** Let users jump back to completed steps by clicking them */32 clickable?: boolean;33 /** Show the Back / Next demo buttons */34 showControls?: boolean;35 /** Label of the Next button */36 nextLabel?: string;37 /** Label of the Back button */38 backLabel?: string;39 /** Optional className merged onto the root */40 className?: string;41 /** Optional inline styles merged onto the root */42 style?: React.CSSProperties;43}44 45const DEFAULT_STEPS: StepperStep[] = [46 { title: "Account", description: "Email and password" },47 { title: "Profile", description: "Name and avatar" },48 { title: "Workspace", description: "Invite your team" },49 { title: "Launch", description: "Review and go live" },50];51 52export function Stepper({53 steps = DEFAULT_STEPS,54 activeStep,55 defaultStep = 1,56 onStepChange,57 orientation = "horizontal",58 size = "md",59 accentColor = "#6366f1",60 showDescriptions = true,61 clickable = true,62 showControls = true,63 nextLabel = "Next",64 backLabel = "Back",65 className = "",66 style,67}: StepperProps) {68 const reduce = useReducedMotion();69 const [inner, setInner] = React.useState(defaultStep);70 const controlled = activeStep !== undefined;71 const count = steps.length;72 const current = Math.min(count, Math.max(0, Math.round(controlled ? activeStep : inner)));73 const done = current >= count;74 75 const go = (n: number) => {76 const next = Math.min(count, Math.max(0, n));77 if (next === current) return;78 if (!controlled) setInner(next);79 onStepChange?.(next);80 };81 82 const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 30 };83 84 return (85 <div86 className={`${styles.root} ${className}`.trim()}87 data-orientation={orientation}88 data-size={size}89 style={{ "--mf-stp-accent": accentColor, ...style } as React.CSSProperties}90 >91 <ol className={styles.list} aria-label="Progress">92 {steps.map((s, i) => {93 const state = i < current ? "complete" : i === current ? "current" : "upcoming";94 const canClick = clickable && i < current;95 const dotInner = (96 <AnimatePresence initial={false}>97 {state === "complete" ? (98 <motion.svg99 key="check"100 aria-hidden="true"101 width="16"102 height="16"103 viewBox="0 0 24 24"104 fill="none"105 stroke="currentColor"106 strokeWidth="3"107 strokeLinecap="round"108 strokeLinejoin="round"109 initial={{ scale: reduce ? 1 : 0.4, opacity: 0 }}110 animate={{ scale: 1, opacity: 1 }}111 exit={{ scale: reduce ? 1 : 0.4, opacity: 0 }}112 transition={spring}113 >114 <motion.path d="M5 12.5 10 17l9-10" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduce ? 0 : 0.3, delay: reduce ? 0 : 0.08 }} />115 </motion.svg>116 ) : (117 <motion.span key="num" aria-hidden="true" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduce ? 0 : 0.15 }}>118 {i + 1}119 </motion.span>120 )}121 </AnimatePresence>122 );123 return (124 <li key={`${s.title}-${i}`} className={styles.step} data-state={state} aria-current={state === "current" ? "step" : undefined}>125 <div className={styles.rail}>126 {canClick ? (127 <button type="button" className={styles.dot} onClick={() => go(i)} aria-label={`Go back to step ${i + 1}: ${s.title}`}>128 {dotInner}129 </button>130 ) : (131 <span className={styles.dot}>{dotInner}</span>132 )}133 {i < count - 1 && (134 <span aria-hidden="true" className={styles.line}>135 <span className={styles.fill} data-filled={i < current ? "" : undefined} />136 </span>137 )}138 </div>139 <div className={styles.text}>140 <span className={styles.title}>141 {s.title}142 <span className={styles.srOnly}>{state === "complete" ? " (completed)" : state === "current" ? " (current step)" : ""}</span>143 </span>144 {showDescriptions && s.description && <span className={styles.desc}>{s.description}</span>}145 </div>146 </li>147 );148 })}149 </ol>150 {showControls && (151 <div className={styles.controls}>152 <p className={styles.status} aria-live="polite">153 {done ? "All steps complete" : `Step ${current + 1} of ${count}: ${steps[current]?.title ?? ""}`}154 </p>155 <div className={styles.buttons}>156 <button type="button" className={styles.secondary} disabled={current === 0} onClick={() => go(current - 1)}>157 {backLabel}158 </button>159 <button type="button" className={styles.primary} onClick={() => go(done ? 0 : current + 1)}>160 {done ? "Start over" : current === count - 1 ? "Finish" : nextLabel}161 </button>162 </div>163 </div>164 )}165 </div>166 );167}