Anatomy of Stepper
How Stepper combines three motion techniques (a CSS color transition, a spring-filled connector, and an SVG pathLength draw) into one 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
animates.
Every circle's tone comes from a single comparison against active, with
no per-step config or variants map. Connectors follow the same logic: the one
between step i and i + 1 fills only once active > i, meaning the step
before it is done and the user has moved past it.
The motion
Advancing a step fires three different animations at once, and each one would be the wrong technique for the other two jobs.
A color swap has no meaningful motion to it, so a transition is cheaper
than mounting a motion component for a property that's either on or off.
The connector is motion (a line growing across visible space), so it gets
the spring that gives every other GodUI surface transition its weight. The
checkmark uses neither a fade nor a scale: pathLength draws the stroke the
way a pen would, which a spring or an opacity fade can't reproduce. Each
animation type matches the shape of its change.
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". It is the same spring, aimed
along whichever axis the layout flows.
The result
One state comparison drives three animations, each matched to the change it shows.
- 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 combines three motion techniques (a CSS color transition, a spring-filled connector, and an SVG pathLength draw) into one 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
animates.
Every circle's tone comes from a single comparison against active, with
no per-step config or variants map. Connectors follow the same logic: the one
between step i and i + 1 fills only once active > i, meaning the step
before it is done and the user has moved past it.
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 would be the wrong technique for the other two jobs.
A color swap has no meaningful motion to it, so a transition is cheaper
than mounting a motion component for a property that's either on or off.
The connector is motion (a line growing across visible space), so it gets
the spring that gives every other GodUI surface transition its weight. The
checkmark uses neither a fade nor a scale: pathLength draws the stroke the
way a pen would, which a spring or an opacity fade can't reproduce. Each
animation type matches the shape of its change.
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". It is the same spring, aimed
along whichever axis the layout flows.
The result
One state comparison drives three animations, each matched to the change it shows.
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 }),
so steps switch instantly with the check already fully drawn.
Motion Score
box-shadowstep-state shadow shares its transition with background/border/color paintscaleScale spring / presspathLengthSVG pathLength reveal