GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Step Indicator UI: 9 Physics-Based Patterns

Nine step indicator ui builds: The designer loved item 03's self-drawing checkmark.

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine Step Indicator UI Patterns — How One Value Decides the Move to the Next Step

All nine step indicators below run on the same idea: one JavaScript number decides how a step change looks, and every visual property in that step reads from it instead of running its own separate tween. Item 01 is the gentlest change — a connector line filling in. By item 05 the value is fighting a user's own drag gesture, and item 09 spends it on three rings at once. The order below moves from a single eased value to inertia, then to staggered and multi-ring math, so each pattern adds one more thing for that value to drive.

01Step Connector That Fills With a Spring Overshoot

integrate() runs a small spring every frame — accel = SPRING_STIFFNESS * (target - pos) - SPRING_DAMPING * vel — so the connector's fill doesn't ease evenly toward 100% — it overshoots past MAX_FILL and springs back. STEP_CONNECTOR_OVERSHOOT_PERCENT (10) sets that ceiling to 1.1, and the same position value drives the node ahead's --glow custom property once it's normalized back to 0–1.

Step Connector That Fills With a Spring Overshoot
Open in a new tab

01 · Step Connector That Fills With a Spring Overshoot — Measured area 22.607% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_CONNECTOR_OVERSHOOT_PERCENTSPRING_STIFFNESSSPRING_DAMPING
  function integrate(s, dt) {
    var accel = SPRING_STIFFNESS * (s.target - s.pos) - SPRING_DAMPING * s.vel;
    s.vel += accel * dt;
    s.pos += s.vel * dt;
    var max = s.target > 0 ? MAX_FILL : 1;
    if (s.target > 0 && s.pos > MAX_FILL) { s.pos = MAX_FILL; s.vel *= -0.35; }
    if (s.target === 0 && s.pos < 0) { s.pos = 0; s.vel = 0; }
    if (Math.abs(s.pos - s.target) < 0.001 && Math.abs(s.vel) < 0.01) { s.pos = s.target; s.vel = 0; }
  }

  function kick(s) { s.vel += (s.target > s.pos ? 1 : -1) * SPRING_KICK_VELOCITY; }

Item 01 repainted 22.6% of the frame at an average intensity of 50.1 across 23/23 measured frames.

02Active Step Node That Breathes With a Damped Pulse

Every frame, envelope0 = Math.exp(-STEP_BREATH_DECAY * t) decays from 1 toward 0 since the last impulse, and that single value sets the circle's --breathGlow, its ring's opacity, and the ring's own inverse scale together. STEP_BREATH_DECAY (2.4) is the only knob — raise it and the breathing dies out within a frame or two instead of visibly settling.

Active Step Node That Breathes With a Damped Pulse
Open in a new tab

02 · Active Step Node That Breathes With a Damped Pulse — Measured area 23.564% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_BREATH_DECAYSTEP_BREATH_FREQSTEP_BREATH_AMPLITUDE
    // spread the same decaying-sine envelope through the ring's opacity and the card's background tone, not just the circle's scale
    var tt0 = (now - impulseAt) / 1000;
    var envelope0 = tt0 < 0 ? 0 : Math.max(0, Math.exp(-STEP_BREATH_DECAY * tt0));
    root.style.setProperty('--breathGlow', envelope0.toFixed(3));
    if (!reduce) {
      var activeCircle = circles[current];
      var s = breathScale(now);
      activeCircle.style.transform = 'scale(' + s.toFixed(4) + ')';
      activeCircle.style.setProperty('--breathGlow', envelope0.toFixed(3));
      var ringEl = nodes[current].querySelector('.bp__ring');
      var ringEnvelope = envelope0;
      ringEl.style.opacity = (ringEnvelope * 0.5).toFixed(3);
      ringEl.style.transform = 'scale(' + (1 + (1 - ringEnvelope) * 0.6).toFixed(3) + ')';
    } else {
      circles[current].style.transform = 'scale(1)';
    }

Item 02 repainted 23.6% of the frame at an average intensity of 55.0 across 23/23 measured frames.

03Completed Step Whose Number Morphs Into a Drawn Checkmark

setDrawProgress() takes one progress value climbing from 0 to 1 and writes it three places at once: the checkmark's strokeDashoffset, the old digit's fading opacity, and the circle's --drawGlow custom property. The highest of the three steps' progress values also sets a shared --cmGlow on the whole card, so a card can glow before any single step technically finishes.

Completed Step Whose Number Morphs Into a Drawn Checkmark
Open in a new tab

03 · Completed Step Whose Number Morphs Into a Drawn Checkmark — Measured area 21.637% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_CHECK_DRAW_MSeaseOutCubicsetDrawProgress
  function setDrawProgress(i, progress) {
    var len = lengths[i];
    paths[i].style.strokeDashoffset = String(len * (1 - progress));
    digits[i].style.opacity = String(1 - progress);
    paths[i].style.opacity = String(progress);
    // spread the same draw-progress value into the node's fill color, ring glow, and card background tone
    circles[i].style.setProperty('--drawGlow', progress.toFixed(3));
    progressState[i] = progress;
    root.style.setProperty('--cmGlow', Math.max(progressState[0], progressState[1], progressState[2]).toFixed(3));
  }

Item 03 repainted 21.6% of the frame at an average intensity of 62.6 across 21/23 measured frames.

04Step Path Line That Splits Based on the Chosen Answer

applyMorph() eases one x-coordinate from the shared trunk toward a branch target with easeOutCubic, then rebuilds the path's d attribute from that coordinate every frame instead of crossfading two fixed paths. How far that coordinate has traveled (0 to MAX_OFFSET) becomes one travelled ratio, which thickens the path from 2.4px toward 5.0px and switches on a drop-shadow past 4% of the distance.

Step Path Line That Splits Based on the Chosen Answer
Open in a new tab

04 · Step Path Line That Splits Based on the Chosen Answer — Measured area 15.582% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_BRANCH_MORPH_MSbuildPathapplyMorph
  function applyMorph(key, pathEl, targetX, fromX) {
    if (reduce) {
      pathEl.setAttribute('d', buildPath(targetX, targetX));
      progress[key] = clamp(Math.abs(targetX - CENTER_X) / MAX_OFFSET, 0, 1);
      paintProgress();
      return;
    }
    var start = null;
    function step(now) {
      if (start === null) start = now;
      var t = clamp((now - start) / STEP_BRANCH_MORPH_MS, 0, 1);
      var eased = easeOutCubic(t);
      var x = lerp(fromX, targetX, eased);
      pathEl.setAttribute('d', buildPath(x, x));
      var travelled = clamp(Math.abs(x - CENTER_X) / MAX_OFFSET, 0, 1);
      progress[key] = travelled;
      pathEl.style.strokeWidth = (2.4 + travelled * 2.6).toFixed(2);
      pathEl.style.filter = travelled > 0.04
        ? 'drop-shadow(0 0 ' + (travelled * 5.5).toFixed(1) + 'px rgba(10, 79, 232, ' + (travelled * 0.55).toFixed(2) + '))'
        : 'none';
      paintProgress();
      if (t < 1) requestAnimationFrame(step);
    }
    requestAnimationFrame(step);
  }

Item 04 repainted 15.6% of the frame at an average intensity of 37.9 across 23/23 measured frames.

05Step Scrubber That Keeps Coasting on Inertia After You Let Go

Letting go of the handle doesn't snap it to a step — coastStep() keeps multiplying whatever velocity was left by STEP_SCRUB_INERTIA_FRICTION (0.85) every frame inside the same continuous tick() loop, until that velocity is nearly zero. Only then does startSettle() ease the handle onto the nearest step; push the friction closer to 1 and the identical loop coasts for many more frames before it lets go.

Step Scrubber That Keeps Coasting on Inertia After You Let Go
Open in a new tab

05 · Step Scrubber That Keeps Coasting on Inertia After You Let Go — Measured area 14.565% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_SCRUB_INERTIA_FRICTIONcoastStepnearestStep
  function coastStep() {
    if (Math.abs(vel) <= VEL_EPS) {
      vel = 0;
      if (!settling) startSettle(nearestStep(pos));
      return;
    }
    vel = vel * STEP_SCRUB_INERTIA_FRICTION;
    pos = clamp(pos + vel, 0, 1);
    render();
  }

Item 05 repainted 14.6% of the frame at an average intensity of 54.2 across 17/23 measured frames.

06Step Ring That Shakes and Tightens on Validation Failure

One decaying sine wave, Math.exp(-STEP_ERROR_SHAKE_DECAY * t) * Math.sin(STEP_ERROR_SHAKE_FREQ * t), becomes an x-offset that shakes the active circle and, scaled down by NEIGHBOR_SHAKE_RATIO, nudges both neighboring circles too. The same decay value also shrinks the ring's diameter by up to STEP_ERROR_RING_CONTRACT_PX (6px) and fades its box-shadow to nothing as the shake settles.

Step Ring That Shakes and Tightens on Validation Failure
Open in a new tab

06 · Step Ring That Shakes and Tightens on Validation Failure — Measured area 37.371% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_ERROR_SHAKE_DECAYSTEP_ERROR_SHAKE_FREQSTEP_ERROR_RING_CONTRACT_PX
    if (!reduce && errorAt !== null) {
      var tt = (now - errorAt) / 1000;
      if (tt <= SHAKE_DURATION_S) {
        var envelope = Math.exp(-STEP_ERROR_SHAKE_DECAY * tt);
        var x = STEP_ERROR_SHAKE_AMPLITUDE_PX * envelope * Math.sin(STEP_ERROR_SHAKE_FREQ * tt);
        circles[current].style.transform = 'translateX(' + x.toFixed(2) + 'px)';
        circles[current].classList.add('is-error');
        var contract = STEP_ERROR_RING_CONTRACT_PX * (1 - envelope);
        var ringSize = RING_BASE_PX - contract;
        rings[current].style.width = ringSize.toFixed(1) + 'px';
        rings[current].style.height = ringSize.toFixed(1) + 'px';
        rings[current].style.marginLeft = (-ringSize / 2).toFixed(1) + 'px';
        rings[current].style.opacity = String(envelope);
        rings[current].style.boxShadow = '0 0 ' + (envelope * 22).toFixed(1) + 'px ' + (envelope * 8).toFixed(1) + 'px rgba(194, 54, 26, ' + (envelope * 0.55).toFixed(2) + ')';
        rings[current].classList.add('is-error');
        glow.style.opacity = (envelope * 0.4).toFixed(2);
        [current - 1, current + 1].forEach(function (idx) {
          if (idx >= 0 && idx < STEP_COUNT) circles[idx].style.transform = 'translateX(' + (x * NEIGHBOR_SHAKE_RATIO).toFixed(2) + 'px)';
        });
      } else {
        clearShakeVisuals();
      }
    }

Item 06 repainted 37.4% of the frame at an average intensity of 41.4 across 19/23 measured frames.

07Skipped Step That Fades and Sinks Into a Ghost

paintSkip() reads one ratio value and sinks the node down by up to STEP_SKIP_SINK_PX (16px) while fading it toward STEP_SKIP_ALPHA_MIN (0.4), eased over STEP_SKIP_TWEEN_MS (320ms). That same ratio widens the node's shadow blur and grows a separate ghost-aura element — both scaled down below a 340px-wide viewport — so sinking, fading, and glowing read as three properties driven by one number.

Skipped Step That Fades and Sinks Into a Ghost
Open in a new tab

07 · Skipped Step That Fades and Sinks Into a Ghost — Measured area 12.444% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_SKIP_SINK_PXSTEP_SKIP_ALPHA_MINpaintSkip
  function paintSkip(ratio) {
    var node = circles[OPTIONAL_INDEX];
    var small = window.innerWidth < 340;
    var sink = STEP_SKIP_SINK_PX * ratio;
    var alpha = 1 - (1 - STEP_SKIP_ALPHA_MIN) * ratio;
    node.style.transform = 'translateY(' + sink.toFixed(2) + 'px)';
    node.style.opacity = alpha.toFixed(3);
    // computed visual layer 1: feed the same sink ratio straight into the node's shadow (blur, spread)
    var shadowBlur = (small ? 3 : 6) + ratio * (small ? 8 : 22);
    var shadowSpread = ratio * (small ? 1 : 4);
    node.style.boxShadow = ratio > 0.01
      ? ('0 ' + (2 + sink * 0.4).toFixed(1) + 'px ' + shadowBlur.toFixed(1) + 'px ' + shadowSpread.toFixed(1) + 'px rgba(0,0,0,' + (0.3 * ratio).toFixed(3) + ')')
      : 'none';
    // computed visual layer 2: a ghost aura that grows and fades on the same ratio (shrunk below a 340px viewport to keep BD9)
    var auraMax = small ? AURA_MAX_PX * 0.3 : AURA_MAX_PX;
    var auraSize = auraMax * ratio;
    ghAura.style.width = auraSize.toFixed(1) + 'px';
    ghAura.style.height = auraSize.toFixed(1) + 'px';
    ghAura.style.opacity = (0.5 * ratio).toFixed(3);
    connectors[OPTIONAL_INDEX - 1].style.opacity = (1 - 0.35 * ratio).toFixed(3);
    connectors[OPTIONAL_INDEX].style.opacity = (1 - 0.35 * ratio).toFixed(3);
  }

Item 07 repainted 12.4% of the frame at an average intensity of 63.9 across 19/23 measured frames.

08Overall Progress Number That Rolls Up as Steps Complete

Each reel calls setReel() with its own delay, i * STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS (30ms), so the last digit always lands slightly after the first two instead of all three changing at once. Inside that delay, easeInOutCubic interpolates the reel's fractional digit into a translateY() offset, and cancelAnimationFrame() stops any reel still spinning from a step change moments before.

Overall Progress Number That Rolls Up as Steps Complete
Open in a new tab

08 · Overall Progress Number That Rolls Up as Steps Complete — Measured area 13.651% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_PROGRESS_DIGIT_ROLL_STAGGER_MSsetReeldigitsOf
  function setReel(i, digit, animate) {
    var el = reels[i];
    var y = -digit * ROW_HEIGHT_EM;
    if (!animate || reduce) {
      el.style.transition = 'none';
      el.style.transform = 'translateY(' + y + 'em)';
      return;
    }
    var delay = i * STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS;
    var fromDigit = shownDigits[i];
    var start = null;
    if (anims[i]) cancelAnimationFrame(anims[i]);
    function step(now) {
      if (start === null) start = now;
      var elapsed = now - start - delay;
      if (elapsed < 0) { anims[i] = requestAnimationFrame(step); return; }
      var t = clamp(elapsed / DIGIT_ROLL_DURATION_MS, 0, 1);
      var eased = easeInOutCubic(t);
      var cur = fromDigit + (digit - fromDigit) * eased;
      el.style.transform = 'translateY(' + (-cur * ROW_HEIGHT_EM) + 'em)';
      if (t < 1) { anims[i] = requestAnimationFrame(step); } else { anims[i] = null; }
    }
    anims[i] = requestAnimationFrame(step);
  }

Item 08 repainted 13.7% of the frame at an average intensity of 65.8 across 23/23 measured frames.

09Completion Ring That Expands and Fades on the Final Step

Three rings start STEP_BURST_STAGGER_S (0.14s) apart, and each grows its radius at STEP_BURST_SPEED_PX_S (85px/s) from its own elapsed time while alpha = 1 - elapsed / STEP_BURST_DURATION_S (0.78s) fades it out. Only the first ring's alpha feeds the card's background glow and the final circle's box-shadow, so three separate rings read as one wave instead of three unrelated fades.

Completion Ring That Expands and Fades on the Final Step
Open in a new tab

09 · Completion Ring That Expands and Fades on the Final Step — Measured area 14.96% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_BURST_RING_COUNTSTEP_BURST_SPEED_PX_SSTEP_BURST_STAGGER_S
      ringEls.forEach(function (el, idx) {
        var tt = elapsed - idx * STEP_BURST_STAGGER_S;
        if (tt < 0) { el.style.opacity = '0'; anyAlive = true; return; }
        if (tt > STEP_BURST_DURATION_S) { el.style.opacity = '0'; return; }
        anyAlive = true;
        var radius = (BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S * tt) * scale;
        var alpha = 1 - tt / STEP_BURST_DURATION_S;
        var d = radius * 2;
        el.style.width = d.toFixed(1) + 'px';
        el.style.height = d.toFixed(1) + 'px';
        el.style.marginLeft = (-radius).toFixed(1) + 'px';
        el.style.marginTop = (-radius).toFixed(1) + 'px';
        el.style.opacity = alpha.toFixed(3);
        // computed visual layer: spread the first ring's (no delay) radius progress and alpha into the card background tone and node glow with the same function
        if (idx === 0) primaryRatio = alpha * clamp(tt / STEP_BURST_DURATION_S, 0, 1);
      });
      if (primaryRatio > 0) {
        var glowMax = (small ? 90 : 220) * primaryRatio;
        cardGlow.style.width = glowMax.toFixed(1) + 'px';
        cardGlow.style.height = glowMax.toFixed(1) + 'px';
        cardGlow.style.opacity = (0.4 * primaryRatio).toFixed(3);
        finalCircle.style.boxShadow = '0 0 ' + (8 + primaryRatio * 26).toFixed(1) + 'px rgba(120, 224, 173, ' + (0.55 * primaryRatio).toFixed(3) + ')';
      }
      if (!anyAlive) burstAt = null;

Item 09 repainted 15.0% of the frame at an average intensity of 90.3 across 22/23 measured frames.

Where a Step Indicator UI Actually Earns Its Place

A step progress indicator ui pattern earns its place wherever someone needs to see both where they are and what's left. Item 01's spring-fill connector suits a general signup or setup flow, where each locked-in step should visibly settle rather than snap. Item 02's breathing node marks a shipping-address step mid-checkout without needing a spinner, and item 03's self-drawing checkmark is built for an identity-verification step a reviewer scans quickly for what's actually done. Item 04 hands a branching quiz its own path split, item 05 gives a five-step tutorial a scrubber the user can drag directly, and item 06's shake explains a blocked "Next" button on any form with a required field. Item 07 fades an optional terms step someone chose to skip, item 08 rolls a dashboard's overall onboarding percentage instead of jumping it, and item 09 closes out a payment flow with its expanding ring. For the branching and resuming logic around steps like these, 9 Multi Step Form UI Patterns — Branch and Resume covers the flow itself, and 9 CSS Timeline Animation Ideas, Copy-Paste is the closer relative for a stepper read top-to-bottom instead of left-to-right.

Nine Step Indicator Patterns, Measured in Area and Intensity

Cumulative area is how much of each fixed-camera frame actually repainted, averaged in; intensity is the average pixel delta where it did. Item 06's error ring shakes the most real estate, item 09's burst is the most intense per pixel it touches, and items 07 and 08 stay quiet because they're single-element reveals, not full-card motion.

| Pattern | Cumulative area | Avg. intensity | Frames moved |

|---|---|---|---|

| 01 Connector spring fill | 22.6% | 50.1 | 23/23 |

| 02 Breathing active node | 23.6% | 55.0 | 23/23 |

| 03 Check morph draw | 21.6% | 62.6 | 21/23 |

| 04 Branch path morph | 15.6% | 37.9 | 23/23 |

| 05 Inertia scrub drag | 14.6% | 54.2 | 17/23 |

| 06 Error ring shake | 37.4% | 41.4 | 19/23 |

| 07 Skip ghost sink | 12.4% | 63.9 | 19/23 |

| 08 Progress digit roll | 13.7% | 65.8 | 23/23 |

| 09 Completion ring burst | 15.0% | 90.3 | 22/23 |

Where the Value Drives the Step Transition — Overshoot, Friction, and Roll Stagger

Three numbers below are doing more work than they look: STEP_CONNECTOR_OVERSHOOT_PERCENT decides how far item 01's connector overshoots past 100% fill before easing back, STEP_SCRUB_INERTIA_FRICTION decides how many extra frames item 05 keeps coasting after you let go, and STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS decides how staggered item 08's three reels land. None of them change what shape draws — they change how long the eye watches it happen, which is most of what separates a progress tracker ui that feels considered from one that just jumps between states. CSS Range Slider: 9 Physics-Grade Custom Sliders tunes that same kind of friction and snap value on a single draggable handle instead of a row of steps.

Where It Breaks — Continuous Loops, Narrow Screens, and Canceled Reels

Seven of these nine — every item except 03 and 08 — run one continuous tick() loop that calls requestAnimationFrame(tick) at its own end and simply reads whatever state a click just changed (vel for item 05, errorAt for item 06, burstAt for item 09) on the next frame, so clicking Next or Skip twice in quick succession never stacks two loops fighting over the same transform. Items 03 and 08 work differently: each keyframe starts its own short-lived requestAnimationFrame chain per call, which is why both explicitly run cancelAnimationFrame(anims[i]) before starting a replacement — skip that guard and a second click mid-animation would leave two chains writing to the same digit or checkmark at once. Below a 340px-wide viewport, items 07 and 09 shrink their ghost aura and completion glow by hand (window.innerWidth < 340) so neither clips against a phone-width card, and the .is-demo class you see auto-play on load runs its own timed schedule — item 05's demo kicks the handle at 0.20s, 0.70s, 1.20s, and 1.70s into a repeating 2-second loop, alternating direction each time, and stops the moment a real pointer or arrow-key input removes that class. The nine index.html/style.scss pairs behind these builds, uqcvfxsq, are the fastest way to see exactly which of the two loop styles a tenth pattern of your own should copy.

Three Variants and Accessibility

| Variant | Value change | Feel |

|---|---|---|

| Adjust the connector-overshoot ratio value | 01 STEP_CONNECTOR_OVERSHOOT_PERCENT 10 → 35 | Lowering the value fills the connector smoothly with almost no overshoot, and raising it sends the line well past its target before it eases back for an exaggerated move. |

| Adjust the scrubber-inertia friction value | 05 STEP_SCRUB_INERTIA_FRICTION 0.85 → 0.95 | Lowering the value stops the scrubber on the nearest step almost as soon as you let go, and raising it weakens the friction so it coasts much longer. |

| Adjust the progress digit-roll stagger interval value | 08 STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS 30 → 100 | Shortening the value changes the digits almost at once, and lengthening it makes each place roll up visibly one at a time. |

prefers-reduced-motion is checked once at the top of every script and jumps straight to each value's resting state instead of turning off individual properties — item 03's checkmark still draws, just at progress = 1 on the first frame, and item 05's tick() skips coastStep() entirely to snap pos onto the nearest step instead. Arrow keys move exactly one step on items 01 through 05 and 08, item 07 answers ArrowRight alone since skipping only ever goes forward, and items 06 and 09 don't listen for arrow keys at all — an error state and a finished flow aren't steps you navigate away from. All nine keep a live status line next to the demo: aria-live="polite" on eight of them, aria-live="assertive" on item 06 so a screen reader interrupts to announce the validation error instead of waiting its turn. transform, prefers-reduced-motion, getBoundingClientRect(), and requestAnimationFrame() are documented on MDN.

How Is a Progress Steps UI Different From This Step Indicator UI?

A progress steps ui search mostly turns up a flat row of circles connected by a line that a single CSS class toggles between "done," "active," and "upcoming" — the step change is instant, and whatever motion exists is a single transition: all 0.3s on the connector's width. Every pattern in this post instead computes at least one extra value from that same state change before touching the DOM: a spring integrating position and velocity, a decaying sine wave, a coasting friction value, so the same three states end up driving a calculation instead of a class swap.

Questions Worth Answering Before You Try One of These Nine

Does any of these nine use a real <input type="range"> or a UI framework's stepper component?

No — all nine are plain divs and SVG paths driven by requestAnimationFrame() and vanilla event listeners; item 05's scrubber reads pointer position and translates it into pos by hand instead of wrapping a native range input, so its inertia math (STEP_SCRUB_INERTIA_FRICTION) is fully visible in the file you unzip.

Which of the nine repaints the most of the frame, and why?

Item 06, at 37.4% cumulative area — a validation error doesn't just recolor one ring, it shakes the active circle, contracts its ring, and nudges both neighboring circles at a reduced ratio every frame the shake plays, so four elements move instead of one.

Is a plain CSS <progress> bar built the same way as these nine step indicators?

No — a native <progress> element only exposes a single fill percentage to style, while every pattern here computes at least one extra layer from that percentage (a spring overshoot, a decaying shake, a staggered digit roll) before it touches the DOM, which a browser's own progress bar has no hook for.

Enter the archive password

The password is inside this article. You will find it as you read.