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

数値ステッパー UI 9選 — 長押しから目盛りスナップまで

9つのうち1つは押された状態が長引くほど値が変わる間隔が短くなり、1つは値が上限に触れるとその地点を少し超えてからばね曲線に沿って戻り、もう1つは引く距離の値に比例して数字が変わり、指を離した後も慣性の値に沿って数段階分さらに進みます。ステッパー uiの9パターンを以下にまとめました。

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

この数値 ステッパーの9つはどれも実在のサーバーに触れません — 07のコイン残高も08のkg・lb換算もその場で計算されるだけで、fetchや決済・在庫のAPIを呼び出す処理はどこにもありません。そのうえで9つとも実際に手を動かした結果に答えます。01と02はpointerdownからpointerup・pointerleave・pointercancelまで続く押し続け操作に、03・07・08はクリックに、04はホイールに、05はpointerdownからpointermove・pointerup・pointercancelまで続くドラッグに、06はkeydownに、それぞれ応答します — 自動再生だけで完結する項目はひとつもありません。

9つのパターンの解剖 — 間隔・距離・目盛りの値がいつ変わるか

9つのパターンは、押し続け・ドラッグ・キー入力という3つの入力を、アニメーションする価値のある値に変える3つの方法に分かれます。この計算層を飛ばすと、ステッパー ボタンが9個とも同じ+/-アイコンの繰り返しにしか見えなくなります。

03・05・09が経過時間や力から直接、物理の式を解く理由

03・05・09は一定区間を0〜1に正規化した進行率をそのまま使いません — それぞれが物理的な式を毎フレーム解きます。03のovershootは境界に触れてから経過した時間を減衰する正弦波に代入し、座席数の値が境界を少し超えてからばね曲線に沿って戻る動きを作ります。05のendDragは指を離した瞬間の速度に指数的な摩擦の項を毎フレーム掛けて減衰させ、体感温度の値にその結果を折り込み続けます。09のspringSettleはハンドルの位置と目標の目盛りとの距離にSTIFFを掛けDAMPで減衰させたばねの力を毎フレーム計算し、ハンドルが実際にその目盛りへ到達するまで止まりません。

01・02・04が値そのものではなく、間隔や閾値を求め直す理由

01・02・04は1回のステップが動く量そのものは変えません — 代わりに次のステップがいつ発火できるかを求め直します。01のringFrameは繰り返し間隔自体をINTERVALのまま固定しつつ、その間隔のうちどれだけ経過したかを毎フレーム計算し直してリングを描き直します。02のcurrentIntervalは同じ種類の間隔自体を縮め、START * DECAY^ticksをティックごとに求め直すことで、押され続ける時間が長いほど変化までの待ち時間が短くなっていきます。04のapplyDeltaはタイマーでは一切発火せず、ホイールの動きを残余値に積み上げ、その残余値がTHRESHを超えたときだけ1段コミットします。

06・07・08が経過時間の比率ではなく、跳躍幅・桁位置・反転角度に紐づく値を計算する理由

06・07・08はそれぞれ単純な経過時間の比率とは別のものに動きを紐づけます。06のjumpは跳躍の大きさそのものの対数 — Math.log2(1 + Math.abs(delta)) — を使って残像がどれだけ長く、どれだけ長い時間かけてスライドするかを決めるので、Home/Endのような大きな跳躍は矢印キー1回分より長く尾を引いて消え、増減する単位そのものはShiftキーによる1か10かの単純な選択のままです。07のstartColumnRollは桁の位置にCASCADEを掛けて自分のロール開始を遅らせるので、1の位の列は必ず1000の位の列より先にロールを始めます。08のflipToggleは反転角度のコサインの絶対値からカードがどれだけ薄くなるかを求め、その薄さが90度の地点でゼロに達した瞬間に単位を切り替えます。

パターン別コード

01長押しリピート — 押された状態が続く間、一定間隔で値が変わり続けるステッパー

ボタンが押された状態になると、在庫数量の値はINTERVALごとに1ずつ変わり続け、それとは別にリングの再描画が次のティックまでの近さを毎フレーム計算し直します。

長押しリピート — 押された状態が続く間、一定間隔で値が変わり続けるステッパー
新しいタブで開く

01 · 長押しリピート — 押された状態が続く間、一定間隔で値が変わり続けるステッパー — 計測 累積面積 0.705% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DELAY=380INTERVAL=220ringFrame

数字が示すもの

23フレーム中14フレームで動きがあり、累積面積は0.71%、強度は136.2です — 表示が二桁の数字と細いリングの線だけなので場面系の項目ほど広くはならず、DELAY(380ms)とINTERVAL(220ms)という2つの定数が触れるのは2つの小さなSVGリングとテキストノードだけです。

var MIN = 0, MAX = 30, DELAY = 380, INTERVAL = 220;
var value = 12;
var held = null;
var lastTick = 0;
var repeatTimer = null;
var delayTimer = null;

function render() { valueEl.textContent = String(value); }

function step(dir) {
  value = Math.max(MIN, Math.min(MAX, value + dir));
  render();
  lastTick = performance.now();
}

// 計算層: 繰り返し間隔自体はINTERVALで固定だが、次のティックまでの残り時間を毎フレーム再計算し
// リングのstroke-dashoffsetに反映する — 静的なトゥイーンではない。
function ringFrame() {
  var now = performance.now();
  var p = held ? Math.min(1, (now - lastTick) / INTERVAL) : 0;
  var offset = CIRC * (1 - p);
  ringMinus.style.strokeDashoffset = (held === 'minus' ? offset : CIRC).toFixed(2);
  ringPlus.style.strokeDashoffset = (held === 'plus' ? offset : CIRC).toFixed(2);
  requestAnimationFrame(ringFrame);
}

02長押し加速 — 押されている時間が長くなるほど間隔の値が短くなるステッパー

ボタンを押し続けると間隔の値が指数曲線に沿って徐々に短くなり下限に近づくため、同じ1秒の押し続けでも後半のほうが前半よりずっと多くのポイントが動きます。

長押し加速 — 押されている時間が長くなるほど間隔の値が短くなるステッパー
新しいタブで開く

02 · 長押し加速 — 押されている時間が長くなるほど間隔の値が短くなるステッパー — 計測 累積面積 0.872% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

START=480DECAY=0.82currentInterval

数字が示すもの

23フレーム中8フレームで動きがあり、累積面積0.87%・強度171.0で、paintMeterがティックごとにバーの塗りを計算し直す分、01の単純なリング再描画よりわずかに上回ります。

var timer = null;
var ticks = 0;

function render() { valueEl.textContent = String(value); }

// 計算層: 間隔をティックごとにSTART*DECAY^ticksで再計算する(指数減衰、下限はFLOOR) —
// setIntervalの固定周期ではなく、再帰的なsetTimeoutで間隔自体を更新する。
function currentInterval() {
  return Math.max(FLOOR, START * Math.pow(DECAY, ticks));
}
function paintMeter() {
  var iv = currentInterval();
  var speed = (START - iv) / (START - FLOOR); // 0(最も遅い)〜1(最も速い)
  meterFill.style.transform = 'scaleX(' + speed.toFixed(3) + ')';
}

function scheduleNext(dir) {
  timer = setTimeout(function () {
    value = Math.max(MIN, Math.min(MAX, value + dir * 5));
    render();
    ticks += 1;
    paintMeter();
    scheduleNext(dir);
  }, currentInterval());
}

03限界バウンス — 値が上限や下限を少し超えてからばね曲線に沿って戻るステッパー

座席数の値が上限や下限に達した状態になると、値はそこで止まらず境界を少し超えた位置まで進んでから、減衰する正弦波に沿って境界の値へ戻ります。

限界バウンス — 値が上限や下限を少し超えてからばね曲線に沿って戻るステッパー
新しいタブで開く

03 · 限界バウンス — 値が上限や下限を少し超えてからばね曲線に沿って戻るステッパー — 計測 累積面積 0.547% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

AMPLITUDE=10FREQ=16autoplayTick

数字が示すもの

23フレーム中15フレームで動きがあり、この9つの中で最も多く、累積面積0.55%・強度126.0です — overshootの減衰正弦波が単純なクランプよりずっと長くtranslateXを描き直し続けるためです。

var AMPLITUDE = 10, FREQ = 16, DECAY = 10;
function overshoot(tSec) {
  if (tSec <= 0) { return 0; }
  return AMPLITUDE * Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec);
}
function bounce(dir) {
  var edge = dir > 0 ? edgeRight : edgeLeft;
  var start = performance.now();
  if (raf) { cancelAnimationFrame(raf); }
  function frame(now) {
    var t = (now - start) / 1000;
    var px = dir * overshoot(t);
    wrapEl.style.transform = 'translateX(' + px.toFixed(2) + 'px)';
    var op = Math.max(0, 1 - t / 0.4);
    edge.style.opacity = op.toFixed(3);
    if (t < 0.45) {
      raf = requestAnimationFrame(frame);
    } else {
      wrapEl.style.transform = 'translateX(0px)';
      edge.style.opacity = '0';
      raf = null;
    }
  }
  raf = requestAnimationFrame(frame);
}

04ホイールデルタ — 入力欄上のホイール移動の値の分だけ同じ向きに値が変わるステッパー

拡大パネルの上でホイールが動くたびにその値が残余値へ積み上がり、その残余値がTHRESHを超えたときだけ表示されている倍率が実際に動きます。

ホイールデルタ — 入力欄上のホイール移動の値の分だけ同じ向きに値が変わるステッパー
新しいタブで開く

04 · ホイールデルタ — 入力欄上のホイール移動の値の分だけ同じ向きに値が変わるステッパー — 計測 累積面積 0.333% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

THRESH=60STEP=10decayFrame

数字が示すもの

9つの中で最も低い累積面積0.33%ながら23フレーム全部で動きがあるのは、decayFrameがほとんどのフレームで細い塗りバーを1ピクセル未満だけ動かし、数字そのものを丸ごと入れ替えることは少ないためです。

function render() { valueEl.textContent = value + '%'; }
function paintTrack() {
  var p = Math.min(1, Math.abs(residual) / THRESH);
  fillUp.style.transform = (residual > 0 ? p : 0).toFixed(3);
  fillDown.style.transform = (residual < 0 ? p : 0).toFixed(3);
}

// 計算層: ホイールのdeltaをresidualに積み上げ、THRESHを超えた分だけ1段コミットする。
// コミットされなかった残りのresidualは毎フレーム0.9倍で減衰し、手を離しても自然に0へ収束する。
function applyDelta(dy) {
  residual += dy;
  while (residual >= THRESH) {
    value = Math.min(MAX, value + STEP);
    residual -= THRESH;
    render();
  }
  while (residual <= -THRESH) {
    value = Math.max(MIN, value - STEP);
    residual += THRESH;
    render();
  }
  paintTrack();
}

05縦ドラッグ慣性 — 引く距離の値に比例して変わり、離した後も数段階分続くステッパー

ハンドルを縦にドラッグするとSTEP_PXごとに体感温度の値が1段変わり、指を離した瞬間に記録された速度が摩擦のループへ引き継がれてさらに数フレーム値を動かし続けます。

縦ドラッグ慣性 — 引く距離の値に比例して変わり、離した後も数段階分続くステッパー
新しいタブで開く

05 · 縦ドラッグ慣性 — 引く距離の値に比例して変わり、離した後も数段階分続くステッパー — 計測 累積面積 3.352% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FRICTION=6.2MAX_OFFSET=34autoplayFling

数字が示すもの

9つの中で最も広い累積面積3.35%・強度154.7を23フレーム中6フレームで記録しています — ハンドルの縦方向の全移動に加えて指を離した後の慣性まで描き直すため、クリック操作より1フレームあたりのピクセル数が多くなります。

var FRICTION = 6.2;
function endDrag() {
  dragging = false;
  handle.classList.remove('nspdi__handle--active');
  var baseValue = value;
  var virtual = 0;
  var v = vel;
  var last = performance.now();
  function frame(now) {
    var dt = now - last; last = now;
    v *= Math.exp(-FRICTION * dt / 1000);
    virtual += v * dt;
    value = Math.max(MIN, Math.min(MAX, baseValue - Math.round(virtual / STEP_PX)));
    render();
    setHandle(virtual);
    if (Math.abs(v) > 0.02) {
      inertiaRaf = requestAnimationFrame(frame);
    } else {
      setHandle(0);
      inertiaRaf = null;
    }
  }
  inertiaRaf = requestAnimationFrame(frame);
}

06キーボードジャンプ — 矢印キーは1単位、修飾キーを加えるとより大きな単位で値が変わるステッパー

矢印キーを押すとポイントの値が1単位変わり、同じ矢印キーをShiftと一緒に押すと10単位変わり、実際の値の後ろでは直前の値を示す残像がその跳躍の大きさに応じてスライドします。

キーボードジャンプ — 矢印キーは1単位、修飾キーを加えるとより大きな単位で値が変わるステッパー
新しいタブで開く

06 · キーボードジャンプ — 矢印キーは1単位、修飾キーを加えるとより大きな単位で値が変わるステッパー — 計測 累積面積 0.785% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX=200Math.log2stopAutoplay

数字が示すもの

23フレーム中9フレームで動きがあり、累積面積0.79%・9つの中で最も低い強度59.6です — 残像はMath.log2で求めたスライド距離と持続時間の分だけ動いて消えるため、1単位のジャンプでは位置の変化よりも透明度の変化のほうが目立ちます。

function jump(oldVal, delta) {
  ghost.textContent = String(oldVal);
  var dist = Math.min(46, 12 + Math.log2(1 + Math.abs(delta)) * 13);
  var dir = delta >= 0 ? -1 : 1;
  var dur = Math.min(520, 220 + Math.abs(delta));
  var start = performance.now();
  if (raf) { cancelAnimationFrame(raf); }
  ghost.style.opacity = '1';
  function frame(now) {
    var t = Math.min(1, (now - start) / dur);
    var eased = 1 - Math.pow(1 - t, 3);
    ghost.style.transform = 'translateY(' + (dir * dist * eased).toFixed(1) + 'px)';
    ghost.style.opacity = (1 - eased).toFixed(3);
    if (t < 1) {
      raf = requestAnimationFrame(frame);
    } else {
      ghost.style.opacity = '0';
      raf = null;
    }
  }
  raf = requestAnimationFrame(frame);
  valueEl.style.transform = 'scale(1.12)';
  setTimeout(function () { valueEl.style.transform = 'scale(1)'; }, 30);
}

07通貨のライブ書式 — 値が変わるたびに桁区切りの位置を求め直すステッパー

値が変わるたびに4つの桁のうちどの列が実際に変わったかが求め直され、変わった列だけが新しい数字へロールし、残りの列はそのまま止まっています。

通貨のライブ書式 — 値が変わるたびに桁区切りの位置を求め直すステッパー
新しいタブで開く

07 · 通貨のライブ書式 — 値が変わるたびに桁区切りの位置を求め直すステッパー — 計測 累積面積 0.493% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CASCADE=60BASE_DUR=220startColumnRoll

数字が示すもの

23フレーム中8フレームで動きがあり、累積面積0.49%・強度125.7です — CASCADEが桁位置ごとに開始を60msずつ遅らせるため、4つの列が同じフレームで一斉に描き直されることはありません。

function startColumnRoll(colEl, oldD, newD, dir, place) {
  var oldEl = colEl.querySelector('.nspcf__digit--old');
  var newEl = colEl.querySelector('.nspcf__digit--new');
  var delay = place * CASCADE;
  var duration = BASE_DUR + place * 40;
  newEl.textContent = String(newD);
  newEl.style.transform = 'translateY(' + (dir * H) + 'px)';
  setTimeout(function () {
    var start = performance.now();
    function frame(now) {
      var t = Math.min(1, (now - start) / duration);
      var eased = 1 - Math.pow(1 - t, 3);
      oldEl.style.transform = 'translateY(' + (dir * H * eased).toFixed(1) + 'px)';
      newEl.style.transform = 'translateY(' + (dir * H * (eased - 1)).toFixed(1) + 'px)';
      if (t < 1) {
        requestAnimationFrame(frame);
      } else {
        oldEl.textContent = String(newD);
        oldEl.style.transform = 'translateY(0)';
        newEl.style.transform = 'translateY(' + (dir * H) + 'px)';
      }
    }
    requestAnimationFrame(frame);
  }, delay);
}

08単位フリップ切替 — 単位が変わった状態になるとカードが裏返り、90度の地点で表示値が切り替わるステッパー

単位が変わった状態になると、フリップカードがコサインの絶対値から自分の縦の比率を求め直し、その比率がゼロに近づく90度の地点でkgとlbの表示を切り替えます — 重なって薄れるクロスフェードではなく、裏返って変わるカットです。

単位フリップ切替 — 単位が変わった状態になるとカードが裏返り、90度の地点で表示値が切り替わるステッパー
新しいタブで開く

08 · 単位フリップ切替 — 単位が変わった状態になるとカードが裏返り、90度の地点で表示値が切り替わるステッパー — 計測 累積面積 0.93% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FACTOR=2.20462Math.cosflipToggle

数字が示すもの

23フレーム中12フレームで動きがあり、これは04を除くと9つの中で最多です。累積面積0.93%・強度79.1で、プラス・マイナス・切り替えのどのボタンを押してもフリップカードの再描画か重さの値の更新のどちらかが起きるためです。

function flipToggle() {
  if (flipRaf) { cancelAnimationFrame(flipRaf); }
  var duration = 340;
  var start = performance.now();
  var swapped = false;
  function frame(now) {
    var t = Math.min(1, (now - start) / duration);
    var angle = t * Math.PI;
    var scaleY = Math.abs(Math.cos(angle));
    flipBox.style.transform = 'scaleY(' + Math.max(0.06, scaleY).toFixed(3) + ')';
    if (!swapped && t >= 0.5) {
      unit = unit === 'kg' ? 'lb' : 'kg';
      render();
      swapped = true;
    }
    if (t < 1) {
      flipRaf = requestAnimationFrame(frame);
    } else {
      flipBox.style.transform = 'scaleY(1)';
      flipRaf = null;
    }
  }
  flipRaf = requestAnimationFrame(frame);
}

09目盛りスナップ — 値が決まった目盛りの間にあると最も近い目盛りの値に引き寄せられるステッパー

ハンドルを2つの目盛りの間のどこで放しても、そこから近いほうの目盛りへ向かうばねの力が働き始め、決まった一方向へ動くわけではありません。

目盛りスナップ — 値が決まった目盛りの間にあると最も近い目盛りの値に引き寄せられるステッパー
新しいタブで開く

09 · 目盛りスナップ — 値が決まった目盛りの間にあると最も近い目盛りの値に引き寄せられるステッパー — 計測 累積面積 3.068% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STIFF=0.28DAMP=0.62springSettle

数字が示すもの

23フレーム中12フレームで動きがあり、累積面積3.07%・強度110.9と、9つの中でいちばん小さな部品でありながら05のドラッグに次ぐ広さを記録しています — ハンドルかばねのどちらかが動いている間は11個の目盛りがpaintのたびに大きさと不透明度を変え続けるためです。

var STIFF = 0.28, DAMP = 0.62;
function springSettle(x0, target) {
  if (settleRaf) { cancelAnimationFrame(settleRaf); }
  var x = x0, v = 0;
  function frame() {
    var force = (target - x) * STIFF;
    v = (v + force) * DAMP;
    x += v;
    paint(x);
    if (Math.abs(target - x) > 0.3 || Math.abs(v) > 0.2) {
      settleRaf = requestAnimationFrame(frame);
    } else {
      x = target;
      paint(x);
      currentX = x;
      settleRaf = null;
    }
  }
  settleRaf = requestAnimationFrame(frame);
}

値がパターンを変える場所 — 加速係数・抵抗曲線・スナップ間隔

3つの定数はパターンの感触だけを変え、パターンが何をするかは変えません — 値を入れ替えても同じクリック・ドラッグ・目盛りは結局同じ最終値に落ち着き、そこへ至る経路だけが変わります。この設計のおかげで数量 ステッパーの見た目を調整するたびにロジックを書き直す必要がありません。

02のDECAYが決めるのは間隔が縮む速さで、繰り返すかどうかではない理由

currentInterval()はSTART * DECAY^ticksをFLOORでクランプして返します。DECAYが1に近いと何十ティック経っても間隔はほとんど縮まず、1から離れるとほぼ即座にFLOORまで落ちますが、scheduleNextが自分自身を再帰的に呼び続けるという「繰り返す」動作そのものはこの定数とは関係がありません。

05のFRICTIONが決めるのは慣性が続く長さで、1回のドラッグが動ける距離ではない理由

endDrag()は指を離した瞬間の速度にMath.exp(-FRICTION * dt / 1000)を毎フレーム掛けるため、FRICTIONが小さいほど体感温度の値は離した後も長く動き続けます。一方で1回のドラッグそのものが動く距離はmoveDrag内のSTEP_PXによる単純な1対1の対応で決まり、FRICTIONとは無関係です。

09のSTIFFとDAMPが決めるのはどう吸着するかで、どの目盛りに吸着するかではない理由

nearestTickX()がspringSettleを呼ぶ前にすでに生のピクセル距離だけで目標の目盛りを決めています。STIFFとDAMPはハンドルがその決まった目標へ向かう経路の力と減衰だけを形づくり、STIFFが低ければゆっくり吸い寄せられ、高ければ一気に吸着しますが、どちらも別の目盛りへ向き先を変えることはできません。

どこで壊れるか — 高速タップ・桁数の急増・範囲外の入力

素早い連打が03のオーバーシュートのアニメーションを最後まで終わらせない場合

bounce(dir)は呼ばれるたびに実行中のrafを即座にキャンセルするため、最初のオーバーシュートがまだ途中の状態で境界付近をもう一度クリックすると、その進行はそこで捨てられて正弦波がt = 0から描き直されます — 座席数の表示は一度ゼロの位置へ視覚的に戻ってから、あらためて外へ振れます。

この9つが入力欄 デザイン 9選 — コピペで動くフォーム部品・html カラーピッカー デザイン9選 — ホイールから履歴色まで・CSS 数字 カウントアップ アニメーション9選 — 一行でコピペOKと違うのは、それぞれの回が実際にどの値を求め直しているかです。この9つの元ファイルはすべてパスワードa3azqrsbで解除でき、架空のラベルを実在の商品名に差し替えるだけで実際の数量欄にそのまま使えます。入力欄の回は主に欄自体の検証状態や書式の値を求め直すだけで、押し続け・ドラッグ・ホイールで動く上限付きの数量の値は扱いません。カラーピッカーの回はホイールやポインター移動から色相・彩度・位置の値を求め直しますが、数値のMINとMAXでクランプする処理はこの9つのようには登場しません。カウントアップの回はスクロールや読み込みで一度だけ発生するイージングのトゥイーンで表示中の数字を求め直すだけで、この9つが毎回応答する押し続け・ドラッグ・ホイール・キー入力の繰り返しは扱いません。

07の値が4桁を超えようとするとどうなるか

commit()はすべてのステップをMAX = 9999でクランプし、toDigits()は常にゼロ埋めした文字列の末尾4文字だけを取り出します。このクランプがあるからコイン残高は安全に保たれていて、これを外して実際に5桁の残高を扱おうとすると、toDigitsは5つ目の列を表示に加える代わりに先頭の桁を静かに落としてしまいます。

04で積み上がったホイールの残余値がMAXやMINを超えた場合

applyDelta()はコミットされる値そのものはMath.min・Math.maxでクランプしますが、whileループはそのクランプに関係なくresidualをTHRESHずつ律儀に消費し続けます。拡大率の上限を超えてスクロールし続けても、表示されている倍率はすでに止まっているのに、塗りバーだけは閾値を超えるたびに点滅するように上下し続けます。

3つのバリエーションとアクセシビリティ

このzipは定数ごとにひとつの値だけを出荷していますが、この3つはまわりのマークアップに触れずに、まったく違う感触へ差し替えられます。

バリエーション 変わるもの 引き換えになるもの
07のCASCADEを60msから140msへ 桁の列がロールを始めるタイミングの間隔が広がる 60msでは数字全体がひとつの塊のように一斉に変わって見えるが、140msにすると1の位と1000の位の間の時間差が目に見えるようになり、機械式の計器盤が1列ずつ繰り上がるように読める
09のSTIFFを0.28から0.5へ ばねがハンドルを目盛りへ引き込む力が強くなる 0.28では指を離してから数フレームかけてゆっくり最も近い目盛りへ寄っていくが、0.5では同じ距離でもずっと速く引き込まれ、磁石がハンドルをつかむような即座のスナップに感じられる
05のFRICTIONを6.2から3へ 指を離した後の減衰がゆっくりになる 6.2では指を離した直後に速度がほぼ死んで1〜2段先で止まるが、3まで下げると減衰が緩やかになり、止まるまでにさらに数段分滑り続ける

このzipが実際に出荷しているのは、ここまでのコードに出てきた220ms・0.28・6.2という値で、より速いCASCADE・より硬いSTIFF・より低いFRICTIONへの差し替えはコードの書き直しではなく定数1行の変更で済みます。

prefers-reduced-motionが有効でも、すべての計算済みの値に手動で届く理由

9つとも自分の自動再生プレビューを始める前にwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesを確認するため、reduced-motion環境の訪問者が押す・ドラッグする・スクロールする・キーを打つといった手動の操作をしても同じ計算済みの値へ届き、止まるのはデモが自分で再生するループだけです。01のリングSVGはこの9つの中で唯一aria-hiddenが付いた純粋に装飾だけの再描画で、それ以外の再描画はすべてすでに自分のaria-label(「数量を減らす」「ポイントを増やす」「座席を減らす」など)を持つコントロールのすぐ後ろにあり、06のrole="spinbutton"のボックスはそのラベルに加えて自分のaria-valueminとaria-valuemaxも持っているため、スクリーンリーダーは下に重なったアニメーションではなくコントロールそのものについて1つの状態だけを読み上げます。

FAQ

ステッパー ui デザインは長押しの繰り返しとキーボード対応、どちらから作るべきですか

キーボード対応から作るべきです — commit()がすでに値をクランプしているので06の矢印キーとHome/End対応を足す追加コストはほとんどなく、ポインターを01や02が意味を持つほど長く押せない訪問者でも06が届く値にはすべて届きます。長押しの繰り返しは、単発のクリックだけでは何十回も押す必要が出るほど範囲が広くなってから足す価値が生まれます。

この9つのどれかが実際のサーバーへ数量を送信・検証していますか

いいえ。ここにある値はすべてローカルのMIN・MAXの組でクランプされてそのまま画面に描かれるだけで、fetchを呼ぶことも実在の在庫を確認することも注文を送信することもなく、コイン残高・座席数・荷物の重さはすべて実在のカタログから取ってきたものではない架空の数字です。

この9つは読むだけの参考例ですか、それともフォームへ貼り付けて使える実装ですか

読むだけでなく貼り付けて使うことを前提に作られています。上に載せたコードはどれも25行以内の核心部分だけで、9つとも外部ライブラリへ手を伸ばすことはなく、置き換えが必要なのはローカルのvalue・MIN・MAXという実際のフォームの状態に合わせるべき部分だけです。

Sources: MDN — Element: wheel イベント · MDN — Window.requestAnimationFrame · MDN — Intl.NumberFormat · MDN — ARIA spinbutton ロール · MDN — prefers-reduced-motion

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

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