UINavigationmotion/react

Stepper

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

  1. Account (completed)Email and password
  2. Profile (current step)Name and avatar
  3. WorkspaceInvite your team
  4. LaunchReview and go live

Step 2 of 4: Profile

Customize

9 props
1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add stepper

Writes 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.

example.tsx
1import { Stepper } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Stepper />6  );7}

Props

PropTypeDefault
steps

Steps to render.

{ title: string; description?: string }[]4-step onboarding demo
activeStep

Controlled active step index (0-based); steps.length means every step is complete.

numberundefined
defaultStep

Initial active step when uncontrolled.

number1
onStepChange

Called with the next active step index.

(step: number) => voidundefined
orientation

Layout direction.

'horizontal' | 'vertical''horizontal'
size

Step circle and text size preset.

'sm' | 'md' | 'lg''md'
accentColor

Color of completed steps, the current ring and connector fill.

string'#6366f1'
showDescriptions

Show step descriptions under titles.

booleantrue
clickable

Completed steps become buttons that jump back to them.

booleantrue
showControls

Show the status line and Back / Next buttons.

booleantrue
nextLabel

Label of the Next button (the last step shows 'Finish').

string'Next'
backLabel

Label of the Back button.

string'Back'
className

Class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/stepper.tsx
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}