GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ステップインジケーター デザイン 9パターン — 物理計算で仕上げる進行段階

このステップインジケーター デザインは9通りある。デザイナーは03番のチェックマークが描かれる瞬間を気に入り、開発者は05番の摩擦値ひとつのせいでスクラバーが毎回違う位置で止まることに手を焼いた。09番では二人とも頷いた — リングの広がり方がちょうどよかった。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

ステップインジケーター デザイン 9パターン — 一つの値が次の段階への移動を決める

以下の9つのステップインジケーターはすべて同じ考え方で動いています。一つのJavaScriptの数値がステップの切り替わり方を決め、そのステップの見た目のプロパティは全部その値を読むだけで、それぞれ別のトゥイーンを走らせてはいません。01番はいちばん穏やかな変化で、接続線が満ちていくだけです。05番になるとその値はユーザー自身のドラッグ操作とせめぎ合い、09番では一つの値を3つのリングに同時に使います。以下の順番は一つのイージング値から慣性へ、さらにずらしと複数リングの計算へと進むので、パターンが進むごとにその値が動かす対象が一つずつ増えていきます。

01スプリングで満ちる接続線のステップ表示

integrate()が毎フレームaccel = SPRING_STIFFNESS * (target - pos) - SPRING_DAMPING * velというばね計算を行うため、接続線の満ちる動きは100%へ均等に近づくのではなく、MAX_FILLを一度追い越してから戻ります。STEP_CONNECTOR_OVERSHOOT_PERCENT(10)がその上限を1.1に決めており、0〜1へ正規化した同じ位置の値が、先にあるノードの--glowにもそのまま渡されます。

スプリングで満ちる接続線のステップ表示
新しいタブで開く

01 · スプリングで満ちる接続線のステップ表示 — 計測 累積面積 22.607% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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; }

01番は面積22.6%・強度50.1・動いたフレーム23/23でした。

02呼吸するように拡大縮小する現在ステップの円

毎フレームenvelope0 = Math.exp(-STEP_BREATH_DECAY * t)が最後の刺激からの経過時間で1から0へ減衰し、その一つの値が円の--breathGlow・リングの不透明度・リング自身の逆方向のスケールをまとめて決めています。STEP_BREATH_DECAY(2.4)が唯一の調整値で、これを大きくすると呼吸はゆっくり収まらず1〜2フレームでほぼ消えてしまいます。

呼吸するように拡大縮小する現在ステップの円
新しいタブで開く

02 · 呼吸するように拡大縮小する現在ステップの円 — 計測 累積面積 23.564% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STEP_BREATH_DECAYSTEP_BREATH_FREQSTEP_BREATH_AMPLITUDE
    // 減衰サイン(envelope)の値を、円の拡大だけでなくリングの光やカード背景トーンにも同じ関数で広げる
    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)';
    }

02番は面積23.6%・強度55.0・動いたフレーム23/23でした。

03数字がチェックマークへ形を変えて描かれる完了ステップ

setDrawProgress()は0から1へ進む一つのprogressの値を受け取り、チェックマークのstrokeDashoffset・消えていく数字の不透明度・円の--drawGlowという3か所へ同時に書き込みます。3つのステップのprogressのうち最大の値が、カード全体の--cmGlowも決めているため、どのステップも完了しきる前からカードが光り始めることがあります。

数字がチェックマークへ形を変えて描かれる完了ステップ
新しいタブで開く

03 · 数字がチェックマークへ形を変えて描かれる完了ステップ — 計測 累積面積 21.637% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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);
    // 描画進捗の値を、ノードの塗り色・リングの光・カード背景トーンへ同じ値で伝える
    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));
  }

03番は面積21.6%・強度62.6・動いたフレーム21/23でした。

04選んだ答えによって経路の線が分かれるステップ表示

applyMorph()は幹から分岐先へ向かう一つのx座標をeaseOutCubicでイージングし、二本の固定パスをクロスフェードするのではなく、そのx座標からパスのd属性を毎フレーム作り直します。その座標がどれだけ進んだか(0〜MAX_OFFSET)を表す一つのtravelled比率が、線の太さを2.4pxから5.0pxまで太らせ、距離の4%を超えるとドロップシャドウを点灯させます。

選んだ答えによって経路の線が分かれるステップ表示
新しいタブで開く

04 · 選んだ答えによって経路の線が分かれるステップ表示 — 計測 累積面積 15.582% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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);
  }

04番は面積15.6%・強度37.9・動いたフレーム23/23でした。

05指を離しても慣性で滑り続けるステップスクラバー

指を離してもハンドルは最寄りのステップへ即座には止まらず、coastStep()が同じ連続するtick()ループの中で、離したときの速度にSTEP_SCRUB_INERTIA_FRICTION(0.85)を毎フレーム掛け続け、速度がほぼゼロになるまで滑らせます。その速度が消えて初めてstartSettle()が最寄りのステップへ寄せます。摩擦の値を1に近づけるほど、同じループが何フレームも長く滑り続けます。

指を離しても慣性で滑り続けるステップスクラバー
新しいタブで開く

05 · 指を離しても慣性で滑り続けるステップスクラバー — 計測 累積面積 14.565% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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();
  }

05番は面積14.6%・強度54.2・動いたフレーム17/23でした。

06検証失敗でリングが震えて締まるステップ

Math.exp(-STEP_ERROR_SHAKE_DECAY * t) * Math.sin(STEP_ERROR_SHAKE_FREQ * t)という一つの減衰サイン波がアクティブな円を揺らすx方向のずれになり、NEIGHBOR_SHAKE_RATIOで縮小した同じ値が両隣の円まで揺らします。同じ減衰値がリングの直径を最大STEP_ERROR_RING_CONTRACT_PX(6px)収縮させ、震えが収まるにつれてボックスシャドウも消えていきます。

検証失敗でリングが震えて締まるステップ
新しいタブで開く

06 · 検証失敗でリングが震えて締まるステップ — 計測 累積面積 37.371% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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();
      }
    }

06番は面積37.4%・強度41.4・動いたフレーム19/23でした。

07スキップしたステップが薄れて沈むゴースト表示

paintSkip()は一つのratioの値を受け取り、ノードを最大STEP_SKIP_SINK_PX(16px)沈ませながら不透明度をSTEP_SKIP_ALPHA_MIN(0.4)まで下げ、STEP_SKIP_TWEEN_MS(320ミリ秒)かけてイージングします。同じ比率がノードの影のぼかしを広げ、別要素のゴーストオーラも育てます。どちらも幅340px未満のビューポートでは縮小されるため、沈み・薄れ・光りの3つが一つの数値だけで決まっているとわかります。

スキップしたステップが薄れて沈むゴースト表示
新しいタブで開く

07 · スキップしたステップが薄れて沈むゴースト表示 — 計測 累積面積 12.444% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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);
    // 計算された視覚レイヤー1: 同じ沈み込み比率(ratio)をノードの影(ぼかし・広がり)へそのまま伝える
    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';
    // 計算された視覚レイヤー2: 同じratioで広がり薄くなるゴーストオーラ(幅340px未満では縮小して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);
  }

07番は面積12.4%・強度63.9・動いたフレーム19/23でした。

08全体進捗の数字が回転して上がるステップ表示

各リールは自分専用の遅延i * STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS(30ミリ秒)を付けてsetReel()を呼ぶため、3桁が同時に変わるのではなく、最後の桁は必ず最初の2桁より少し遅れて止まります。その遅延の中でeaseInOutCubicが桁の小数部分をtranslateY()のオフセットへ変換しており、cancelAnimationFrame()が直前のステップ変更で回っていたリールを止めてから次を始めます。

全体進捗の数字が回転して上がるステップ表示
新しいタブで開く

08 · 全体進捗の数字が回転して上がるステップ表示 — 計測 累積面積 13.651% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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);
  }

08番は面積13.7%・強度65.8・動いたフレーム23/23でした。

09最後のステップでリングが広がって消える完了演出

3つのリングはSTEP_BURST_STAGGER_S(0.14秒)ずつ遅れて始まり、それぞれ自分の経過時間からSTEP_BURST_SPEED_PX_S(85px/秒)で半径を広げながら、alpha = 1 - elapsed / STEP_BURST_DURATION_S(0.78秒)で薄くなっていきます。最初のリング(遅延なし)のアルファだけがカード背景のグロウと最後の円のボックスシャドウにも伝わるため、3つの独立したリングが1つの波のように見えます。

最後のステップでリングが広がって消える完了演出
新しいタブで開く

09 · 最後のステップでリングが広がって消える完了演出 — 計測 累積面積 14.96% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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);
        // 計算された視覚レイヤー: 最初のリング(遅延なし)の半径進捗とアルファを、同じ関数でカード背景トーン・ノードの光へ伝える
        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;

09番は面積15.0%・強度90.3・動いたフレーム22/23でした。

ステップインジケーター デザイン 9パターンを面積と強度で測定した結果

累積面積は固定カメラで撮った各フレームのうち実際に塗り替わった割合を平均したもので、強度はその範囲内でのピクセル差の平均です。06番のエラーリングがいちばん広い面積を揺らし、09番のバーストは触れたピクセルあたりの強度が最も高く、07番と08番は画面全体ではなく一つの要素だけが変わる演出なので数値は静かです。

| ステップ | 累積面積 | 平均強度 | 動いたフレーム |

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

| 01 コネクタースプリング満ち | 22.6% | 50.1 | 23/23 |

| 02 呼吸するアクティブノード | 23.6% | 55.0 | 23/23 |

| 03 チェックモーフ描画 | 21.6% | 62.6 | 21/23 |

| 04 分岐パスモーフ | 15.6% | 37.9 | 23/23 |

| 05 慣性スクラブドラッグ | 14.6% | 54.2 | 17/23 |

| 06 エラーリングシェイク | 37.4% | 41.4 | 19/23 |

| 07 スキップゴーストシンク | 12.4% | 63.9 | 19/23 |

| 08 進捗桁ロール | 13.7% | 65.8 | 23/23 |

| 09 完了リングバースト | 15.0% | 90.3 | 22/23 |

ステップ フォーム デザインが実際に使われる場面

ステップ フォーム デザインが力を発揮するのは、今どこにいて何が残っているかを同時に見せたい場面です。01番のスプリングで満ちる接続線は一般的な会員登録や初期設定フローに向いており、各ステップがスナップではなくきちんと沈み込んで定着して見えます。02番の呼吸するノードはチェックアウト中の配送先ステップをスピナーなしで示し、03番の自動描画チェックマークは、確認する人が本人確認ステップの完了状況をひと目で追えるように作られています。04番は分岐するアンケートに専用の経路分岐を与え、05番は5段階のチュートリアルに直接ドラッグできるスクラバーを渡し、06番の震えは必須項目が空のフォームで「次へ」が押せない理由を一目で説明します。07番はユーザーが選んでスキップした利用規約ステップを薄れさせ、08番はダッシュボードの全体進捗パーセントを瞬時に切り替えず桁ごとに回転させ、09番は決済フローの最後をリングの広がりで締めくくります。こうしたステップまわりの分岐・再開ロジックそのものはフォーム ステップ ui 9選 — 分岐と再開までで扱っており、上から下へ読むステッパーの近い親戚としては【コピペOK】タイムライン デザイン css 9選があります。

値がステップの切り替えを決める場所 — オーバーシュート・摩擦・ロール間隔

以下の3つの数値は見た目以上の仕事をしています。STEP_CONNECTOR_OVERSHOOT_PERCENTは01番の接続線が100%をどれだけ超えてから戻るかを決め、STEP_SCRUB_INERTIA_FRICTIONは05番が指を離した後に何フレーム滑り続けるかを決め、STEP_PROGRESS_DIGIT_ROLL_STAGGER_MSは08番の3つのリールがどれだけずれて止まるかを決めています。どれも描かれる形そのものは変えず、目がそれをどれだけ長く追うかだけを変えており、それこそがよく考えられたステップ バー デザインと、状態がただ切り替わるだけのUIを分ける部分です。同じ種類の摩擦とスナップの値を、複数のステップではなく一本のドラッグ可能なハンドルで調整しているのがcss レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種です。

どこで壊れるか — 連続ループ・狭い画面・キャンセルされるリール

9つのうち03番と08番を除く7つは、requestAnimationFrame(tick)を自分の末尾で呼び続ける一つのtick()ループだけで動いています。クリックが変えた状態(05番ならvel、06番ならerrorAt、09番ならburstAt)を次のフレームで読み直すだけなので、NextやSkipを連打しても同じtransformを取り合う二つのループが重なることはありません。03番と08番だけは仕組みが違い、呼び出しごとに短命なrequestAnimationFrameの連鎖を新しく始めるため、どちらも次を始める前にcancelAnimationFrame(anims[i])を呼んでいます。このガードがなければ、アニメーション中にもう一度クリックしたとき、同じ数字やチェックマークを二つの連鎖が同時に書き換えてしまいます。幅340px未満のビューポートでは07番と09番がゴーストオーラと完了グロウのサイズをwindow.innerWidth < 340で手動で縮小しており、読み込み時に自動再生される.is-demoクラスにも独自の時間割があります。05番のデモは2秒周期の中で0.20秒・0.70秒・1.20秒・1.70秒ごとにハンドルを弾き、そのたびに方向を反転させ、実際のポインター操作や矢印キーが.is-demoを外した瞬間に止まります。この9本のindex.html・style.scssの組を守っているuqcvfxsqは、二つのループ方式のどちらを自分の10個目のパターンで真似るべきか見極める一番の近道です。

変形3つとアクセシビリティ

| 変形 | 値 | 感じ方 |

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

| 接続線オーバーシュート比率値を調整する | 01 STEP_CONNECTOR_OVERSHOOT_PERCENT 10 → 35 | 値を小さくすると接続線はほとんど飛び出さず滑らかに満ちていき、値を大きくすると線が目標を大きく超えてから戻る、誇張された動きになる。 |

| スクラバー慣性摩擦値を調整する | 05 STEP_SCRUB_INERTIA_FRICTION 0.85 → 0.95 | 値を小さくすると指を離してすぐ近いステップで止まり、値を大きくすると摩擦が弱まりずっと長く滑り続ける。 |

| 進捗数字ロールの間隔値を調整する | 08 STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS 30 → 100 | 値を短くすると桁がほぼ同時に変わり、値を長くすると一桁ずつはっきり回転していく。 |

prefers-reduced-motionはどのスクリプトでも冒頭で一度だけ確認され、個々のプロパティを止めるのではなく各値をそのまま最終状態へ進めます。03番のチェックマークは変わらず描かれますが、最初のフレームでprogress = 1になるだけです。05番のtick()はcoastStep()を呼ばず、posを最も近いステップへ直接合わせます。矢印キーは01番から05番と08番でちょうど1ステップ分動き、07番はスキップが前方にしか進まないためArrowRightだけに応答し、06番と09番は矢印キーをまったく聞いていません。エラー状態と完了した流れはキーボードで移動する対象のステップではないからです。9つとも状態を読み上げるライブ領域を持っており、8つはaria-live="polite"、06番だけはaria-live="assertive"で、スクリーンリーダーが順番を待たずにバリデーションエラーを割り込んで読み上げます。transform、prefers-reduced-motion、getBoundingClientRect()、requestAnimationFrame()はいずれもMDNに解説があります。

このステップ バー デザインとステップインジケーターは何が違うのか?

ステップ バー デザインで検索すると、多くは丸をつないだ一本の帯で、CSSのクラスひとつが「完了」「進行中」「未着手」を切り替えるだけの実装が出てきます。ステップの切り替えは瞬時で、動きがあったとしても接続線の幅にtransition: all 0.3sをかけている程度です。この記事の各パターンはその同じ状態変化から、ばねの位置と速度・減衰するサイン波・滑り続ける摩擦値といった、DOMに触れる前の計算をもう一段挟んでいる点が違います。

この9つを試す前によくある質問に答えます

この9つは<input type="range">やUIフレームワークのステッパー部品を使っていますか?

いいえ、9つともプレーンなdivとSVGパスをrequestAnimationFrame()と素のイベントリスナーだけで動かしています。05番のスクラバーはポインター位置を読み取ってposへ自分で変換しており、ネイティブのrange要素を包んでいるわけではないので、慣性の計算式(STEP_SCRUB_INERTIA_FRICTION)がzipの中でそのまま見えます。

9つの中でいちばん画面を塗り替えているのはどれで、その理由は何ですか?

06番です。面積は37.4%で、値が空のまま次へ進もうとしたときの震えは一つの輪の色を変えるだけでなく、アクティブな円を震わせ、輪を収縮させ、両隣の円まで縮小した比率で揺らすため、1フレームごとに4つの要素が同時に動きます。

普通のCSSの<progress>バーもこの9つと同じ仕組みで作られていますか?

いいえ、ネイティブの<progress>要素はスタイルできる値が塗りの割合ひとつだけですが、この記事の各パターンはその割合からばねのオーバーシュートや減衰する震え、ずらした桁ロールといった計算をもう一段加えてからDOMに反映しており、ブラウザ標準のprogressバーにはそのための仕組みがありません。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。