Anatomy of Stepper
How Stepper mixes three different motion techniques — a CSS color transition, a spring-filled connector, and an SVG pathLength draw — into one clean step-forward beat.
↓Scroll to step through it
One stateFor(i), three tones
Stepper looks like one animation when a step completes, but it's three
independent techniques layered together, each picked for what it's
animating rather than for consistency's sake.
Every circle's tone comes from a single comparison against active — no
per-step config, no variants map. Connectors follow the same logic: the one
between step i and i + 1 fills only once active > i — the step before
it is actually done, not just reached.
The motion
Advancing a step fires three different animations at once, and each one is deliberately the "wrong" technique for the other two jobs.
Why three techniques, not one? A color swap has no meaningful "motion" to
it — a transition is cheaper than mounting a motion component for a
property that's either on or off. The connector is motion — it's a line
growing across visible space — so it gets the spring that gives every other
Myndui surface transition its felt weight. The checkmark isn't a fade or a
scale at all; pathLength draws the stroke the way a pen would, which a
spring or an opacity fade can't reproduce. Each animation type is chosen for
the shape of the change, not applied uniformly across the component.
Horizontal vs. vertical: the same Connector component renders either
orientation from one prop — only the animated axis and its origin flip.
animate={horizontal ? { scaleX: filled ? 1 : 0 } : { scaleY: filled ? 1 : 0 }}
className={horizontal ? "mt-[17px] h-0.5 flex-1" : "my-1 w-0.5 flex-1 self-center"}scaleX from origin-left reads as "filling left to right"; scaleY
from origin-top reads as "filling downward" — the same spring, aimed
along whichever axis the layout actually flows.
The result
One state comparison, three purpose-built animations, and a stepper that never feels like it's animating for animation's sake.
- Complete
- border-fg, filled, check drawn
- Active
- border-fg, hollow, ring-4
- Upcoming
- border-border, muted text
const stateFor = (i: number): StepState =>i < active ? "complete" : i === active ? "active" : "upcoming";Anatomy of Stepper
How Stepper mixes three different motion techniques — a CSS color transition, a spring-filled connector, and an SVG pathLength draw — into one clean step-forward beat.
- Complete
- border-fg, filled, check drawn
- Active
- border-fg, hollow, ring-4
- Upcoming
- border-border, muted text
const stateFor = (i: number): StepState =>i < active ? "complete" : i === active ? "active" : "upcoming";One stateFor(i), three tones
Stepper looks like one animation when a step completes, but it's three
independent techniques layered together, each picked for what it's
animating rather than for consistency's sake.
Every circle's tone comes from a single comparison against active — no
per-step config, no variants map. Connectors follow the same logic: the one
between step i and i + 1 fills only once active > i — the step before
it is actually done, not just reached.
complete
active
- Circle
- CSS transition, 250ms ease
- Connector
- spring(320, 32, 0.9), slight overshoot
- Check
- pathLength 0 → 1, 0.3s easeOut
// the circle's border/background/text color — a plain CSS transitionclassName="[transition:background-color_250ms_ease,border-color_250ms_ease,color_250ms_ease,box-shadow_250ms_ease]"// the connector fill — a spring, so it can overshoot slightly<motion.spananimate={{ scaleX: filled ? 1 : 0 }}transition={{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}/>// the checkmark — a pathLength tween, drawing the stroke rather than fading it in<motion.pathd="M5 13l4 4L19 7"initial={{ pathLength: 0 }}animate={{ pathLength: 1 }}transition={{ duration: 0.3, ease: "easeOut" }}/>The motion
Advancing a step fires three different animations at once, and each one is deliberately the "wrong" technique for the other two jobs.
Why three techniques, not one? A color swap has no meaningful "motion" to
it — a transition is cheaper than mounting a motion component for a
property that's either on or off. The connector is motion — it's a line
growing across visible space — so it gets the spring that gives every other
Myndui surface transition its felt weight. The checkmark isn't a fade or a
scale at all; pathLength draws the stroke the way a pen would, which a
spring or an opacity fade can't reproduce. Each animation type is chosen for
the shape of the change, not applied uniformly across the component.
Horizontal vs. vertical: the same Connector component renders either
orientation from one prop — only the animated axis and its origin flip.
animate={horizontal ? { scaleX: filled ? 1 : 0 } : { scaleY: filled ? 1 : 0 }}
className={horizontal ? "mt-[17px] h-0.5 flex-1" : "my-1 w-0.5 flex-1 self-center"}scaleX from origin-left reads as "filling left to right"; scaleY
from origin-top reads as "filling downward" — the same spring, aimed
along whichever axis the layout actually flows.
The result
One state comparison, three purpose-built animations, and a stepper that never feels like it's animating for animation's sake.
Accessibility
The active step carries aria-current="step" so assistive tech announces
where the user is without relying on visual state alone. Under
prefers-reduced-motion, every transition collapses to { duration: 0 }
and the checkmark's initial is skipped (reduceMotion ? false : { pathLength: 0 })
— steps switch instantly, with the check already fully drawn rather than
animating in.
Motion Score
box-shadowstep-state shadow shares its transition with background/border/color paintscaleScale spring / presspathLengthSVG pathLength reveal