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

css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種

css レンジスライダーは値が変わるたびに計算が動く一枚の層です。ドラッグを離すと速度ベースの慣性lerpが減衰して止まり、値が変わるたびにスプリングでオーバーシュートしてから収まります。目盛りスナップ・縦型ボリューム・温度グラデーショントラックまで — css レンジスライダー9種、コードはパスワードzipで。

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

デモ

9項目とも同じ骨格((value - min) / (max - min) * 100 を--pctに入れ、requestAnimationFrameの中で毎フレーム計算し直す)を使っていますが、その上に乗る計算層(慣性lerp・スプリング・スナップ・色補間)は項目ごとに違います。実測値は次の表のとおりです。

番号 項目 累積面積 強度平均 動いたフレーム
01 値のバブル 6.988% 183.8 22/23
02 スプリングオーバーシュート 6.238% 134.6 22/23
03 ドラッグ慣性 1.847% 133.6 19/23
04 フィル光沢 4.78% 127.7 21/23
05 目盛りスナップ 3.133% 193.8 19/23
06 縦型ボリューム 2.646% 119.7 20/23
07 キーボード精密調整 4.829% 135.5 20/23
08 色温度トラック 5.576% 94.7 21/23
09 リデュースモーション 2.876% 185.3 21/23

01の値バブルが6.988%で最も広く、03のドラッグ慣性が1.847%で最も狭いのは、トラックとつまみという実際の大きさをそのまま保っているからで、面積を無理に広げていません。強度平均は08の色温度トラックが94.7で最も落ち着き(低彩度のパステル調)、05の目盛りスナップが193.8で最も明るく、9項目とも24フレームのうち19〜22フレームで実際に動きます。

input[type=range] の解剖 — トラック・つまみ・フィルの構造とスタイルの境界

ネイティブのinput[type=range]は既定のトラック・つまみを持っていますが、appearance: noneはその見た目だけを消し、input要素自体はキーボード操作とスクリーンリーダー対応を持ったまま残ります。スライダー デザイン css を検索してこの記事に来た人ほど、まずこの境界を知りたいはずです。

::-webkit-slider-thumbとつまみの境界

つまみの色・形・大きさは::-webkit-slider-thumbと::-moz-range-thumbの2つの疑似要素だけで決まり、この疑似要素はあくまで見た目の上塗りで、フォーカスや値の変更を検知するのはinput自身です。

::-webkit-slider-runnable-trackとトラックの境界

トラックの背景・太さ・角丸は::-webkit-slider-runnable-trackと::-moz-range-trackで描き、Firefoxはトラックを2つの疑似要素(進行前・進行後)に分けて塗るため、Chromeと同じ見た目にするにはgradientの分岐点を揃える必要があります。

値の比率を--pctに集約する一つの式

(value - min) / (max - min) * 100を1つの式としてCSS変数--pctに入れておけば、min・max・valueのどれが変わっても同じ式が再計算し、ラベル位置や塗り幅がcalc(var(--pct) * 1%)で自動的に合います。

9つのスライダーの段階別コード

9項目とも値の比率を--pctに集約する式は共通ですが、その上でrequestAnimationFrameが毎フレーム回す計算(指数減衰・スプリング積分・慣性減衰・目盛り丸め・色補間)は項目ごとに異なります。自動演示の目標値はすべてmulberry32のシード固定乱数から取るため、キャプチャごとに同じ動きが再現されます。

01値のバブル — トラック位置を百分率に変えて追従する値ラベル

現在値と最小・最大値から(value - min) / (max - min) * 100を計算してCSS変数に入れると、バブルラベルがleft: calc(var(--pct) * 1%)でつまみを正確に追従します。累積面積6.988%・面積最大2.02%・強度平均183.8・動いたフレーム22/23です。

値のバブル — トラック位置を百分率に変えて追従する値ラベル
新しいタブで開く

01 · 値のバブル — トラック位置を百分率に変えて追従する値ラベル — 計測 累積面積 6.988% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermoveCSS custom propertiescalc

目標値へ指数減衰で近づくステップループ

自動演示は各目標値へk = 1 - Math.exp(-t / TAU)の指数減衰で滑らかに近づき、@keyframesは使わずrequestAnimationFrameの中で毎フレーム値を再計算します。

    (function step(now) {
      if (playing) {
        var elapsed = now - segStart;
        if (elapsed >= SEG_MS) {
          seg = (seg + 1) % targets.length;
          segStart = now;
          segFrom = current;
          elapsed = 0;
        }
        var target = targets[seg];
        var t = elapsed / 1000;
        var k = 1 - Math.exp(-t / TAU);
        current = segFrom + (target - segFrom) * k;
        range.value = String(Math.round(current));
        paint(current);
      }
      requestAnimationFrame(step);
    })(segStart);

02スプリングオーバーシュート — 値が変わるとつまみが跳ねて収まる減衰振動

つまみのscaleをaccel = -K(scale-1) - Cvelocityというスプリング減衰方程式でrequestAnimationFrameの中で積分すると、値が変わるたびに目標を少し飛び越えてから戻る跳ねが出ます。累積面積6.238%・面積最大1.778%・強度平均134.6・動いたフレーム22/23です。

スプリングオーバーシュート — 値が変わるとつまみが跳ねて収まる減衰振動
新しいタブで開く

02 · スプリングオーバーシュート — 値が変わるとつまみが跳ねて収まる減衰振動 — 計測 累積面積 6.238% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrameCSS custom propertiestransform: scale

KICKで速度に力積を加えて跳ねを始める

目標値が切り替わる瞬間にvelにKICK分をそのまま加えると、その一撃を受けてスプリングが揺れ始め、K・Cの2つの係数だけで跳ねの強さと収まる速さが決まります。

    (function step(now) {
      var dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      if (playing) {
        var elapsed = now - segStart;
        if (elapsed >= SEG_MS) {
          seg = (seg + 1) % targets.length;
          segStart = now; elapsed = 0; posFrom = pos;
          vel += KICK; // 目標が変わるたびに弾みを与えるキック
        }
        var target = targets[seg];
        var t = elapsed / SEG_MS;
        pos = posFrom + (target - posFrom) * t;
        range.value = String(Math.round(pos));
      }
      var acc = -K * (scale - 1) - C * vel;
      vel += acc * dt;
      scale += vel * dt;
      paint();
      requestAnimationFrame(step);
    })(segStart);

03ドラッグ慣性 — ポインター速度を追跡し、指数減衰で滑らせるlerp

pointermoveの移動量から瞬間速度を記録しておき、離した瞬間から毎フレームMath.pow(DECAY, dt)を掛けて速度を減衰させながら位置に加えると、氷の上を滑るような慣性が付きます。累積面積1.847%・面積最大0.931%・強度平均133.6・動いたフレーム19/23です。

ドラッグ慣性 — ポインター速度を追跡し、指数減衰で滑らせるlerp
新しいタブで開く

03 · ドラッグ慣性 — ポインター速度を追跡し、指数減衰で滑らせるlerp — 計測 累積面積 1.847% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramepointermoveCSS custom properties

DECAY_PER_SECで秒単位に揃えた減衰

DECAYは基準12fpsからDECAY_PER_SEC = Math.pow(DECAY, 基準FPS)に変換して秒単位の減衰量として使うため、実機のフレームレートが変わっても同じ手触りになります。

    (function step(now) {
      var dt = now - last; last = now;
      if (!dragging) {
        if (now - lastKick > KICK_EVERY_MS && Math.abs(velocity) < STOP_THRESHOLD) {
          velocity = (rand() - 0.5) * 160; // 決定的な「離した瞬間」の速度(パーセント/秒)
          lastKick = now;
        }
        if (Math.abs(velocity) >= STOP_THRESHOLD) {
          pos += velocity * (dt / 1000);
          velocity *= Math.pow(DECAY_PER_SEC, dt / 1000);
          if (pos < MIN) { pos = MIN; velocity = 0; }
          if (pos > MAX) { pos = MAX; velocity = 0; }
          if (Math.abs(velocity) < STOP_THRESHOLD) velocity = 0;
          range.value = String(Math.round(pos));
        }
      }
      paint();
      requestAnimationFrame(step);
    })(last);

04フィル光沢 — 塗り区間を値比率で切り、ハイライトを流すsweep

塗り幅を--pctで作り、その上に斜めのハイライト帯を重ねてbackground-clipで塗りの内側だけに閉じ込めると、値が増えるほど光沢が塗りの先端に沿って流れます。累積面積4.78%・面積最大1.346%・強度平均127.7・動いたフレーム21/23です。

フィル光沢 — 値比率で塗り区間を切り取り、ハイライトを流すsweep
新しいタブで開く

04 · フィル光沢 — 値比率で塗り区間を切り取り、ハイライトを流すsweep — 計測 累積面積 4.78% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

linear-gradientbackground-clipCSS custom properties

sweepTが周期を作る計算

sweepT = ((now-start)/1000 % SWEEP_PERIOD) / SWEEP_PERIODという式が0から1へ繰り返し戻る値を作り、これをCSS変数--sweepに渡すことでハイライトの流れる速さをSWEEP_PERIODだけで調整できます。

    (function step(now) {
      if (playing) {
        var elapsed = now - segStart;
        if (elapsed >= SEG_MS) {
          seg = (seg + 1) % targets.length;
          segStart = now; elapsed = 0; segFrom = current;
        }
        var target = targets[seg];
        var t = elapsed / 1000;
        var k = 1 - Math.exp(-t / TAU);
        current = segFrom + (target - segFrom) * k;
        range.value = String(Math.round(current));
      }
      var sweepT = ((now - start) / 1000) % SWEEP_PERIOD / SWEEP_PERIOD;
      paint(current, sweepT);
      requestAnimationFrame(step);
    })(start);

05目盛りスナップ — 最寄り目盛りを探し復元力で跳ねて張り付く階段関数

値から目盛り間隔の倍数をMath.round(value / step) * stepで求めて最も近い目盛りを決め、そこへ即座に飛ばすのではなくacc = -K(pos-tickTarget) - Cvelのスプリングで追いかけさせます。累積面積3.133%・面積最大0.879%・強度平均193.8・動いたフレーム19/23です。

目盛りスナップ — 最寄り目盛りを探し復元力で跳ねて張り付く階段関数
新しいタブで開く

05 · 目盛りスナップ — 最寄り目盛りを探し復元力で跳ねて張り付く階段関数 — 計測 累積面積 3.133% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointeruprequestAnimationFrameCSS custom properties

tickTargetは毎フレーム再計算される着地点

round()が決めるのは着地点tickTargetだけで、posがそこへ実際に収まるのは毎フレームのスプリング積分によるため、スナップ直前の1〜2フレームのわずかな押し込みが「張り付く」感覚を作ります。

    (function step(now) {
      var dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      if (playing) {
        var elapsed = now - segStart;
        if (elapsed >= SEG_MS) {
          seg = (seg + 1) % segTargets.length;
          segStart = now; elapsed = 0; segFrom = rawTarget;
        }
        var target = segTargets[seg];
        var t = elapsed / 1000;
        var k = 1 - Math.exp(-t / TAU);
        rawTarget = segFrom + (target - segFrom) * k;
      }
      var tickTarget = Math.round(rawTarget / STEP) * STEP;
      var acc = -K * (pos - tickTarget) - C * vel;
      vel += acc * dt;
      pos += vel * dt;
      range.value = String(Math.round(pos));
      paint();
      requestAnimationFrame(step);
    })(last);

06縦型ボリューム — 書字方向の回転と塗り方向の反転計算

横型のinput[type=range]をtransform: rotate(-90deg)で縦に立てると座標系も一緒に回るため、塗りは(100% - var(--pct))に反転させず、var(--pct)そのままで下から上に満ちるボリュームバーになります。累積面積2.646%・面積最大1.242%・強度平均119.7・動いたフレーム20/23です。

縦型ボリューム — 書字方向の回転と塗り方向の反転計算
新しいタブで開く

06 · 縦型ボリューム — 書字方向の回転と塗り方向の反転計算 — 計測 累積面積 2.646% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

writing-modetransform: rotateCSS custom properties

var(--pct)のままで塗りが下から満ちる理由

回転後はネイティブのつまみのローカル0%が画面の下に来るため、塗りの基準点も同じ下側から始まっており、反転させると塗りが画面上側の断片に落ちてつまみから分離してしまうことをレンダリング確認の上で確定しています。

  display: flex;
  align-items: center;
  justify-content: center;
  transform: rotate(-90deg);
  --pct: 60;
}

.vv__range {
  appearance: none;
  -webkit-appearance: none;
  width: 132px;
  height: 18px;
  margin: 0;
  background: transparent;
}

.vv__range:focus-visible {
  outline: 2px solid #17141a;

07キーボード精密調整 — 矢印・Shiftのステップ幅とaria-valuetextの同期

keydownの矢印キー・Shift増分(fine=1・coarse=10)を同じスプリング目標にそのまま渡し、同じフレームでaria-valuetextを(v/10).toFixed(1) + '秒'で更新すると、キーボードでもポインターと同じ計算経路が動きます。累積面積4.829%・面積最大1.345%・強度平均135.5・動いたフレーム20/23です。

キーボード精密調整 — 矢印・Shiftのステップ幅とaria-valuetextの同期
新しいタブで開く

07 · キーボード精密調整 — 矢印・Shiftのステップ幅とaria-valuetextの同期 — 計測 累積面積 4.829% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

keydownCSS custom propertiesaria-valuetext

fine・coarseの2値だけで増分を切り替える

Shiftキーを押しているかどうかだけでdeltaをfineとcoarseの2値に切り替え、それをtargetに加えるだけなのでポインター入力と別の計算経路を持ちません。

  range.addEventListener('keydown', function (e) {
    var fine = 1, coarse = 10;
    var delta = 0;
    if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = e.shiftKey ? coarse : fine;
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = e.shiftKey ? -coarse : -fine;
    else return;
    e.preventDefault();
    stop();
    stepEl.textContent = e.shiftKey ? 'Shift+矢印キー1秒' : '矢印キー0.1秒';
    target = Math.max(MIN, Math.min(MAX, target + delta));
    range.value = String(target);
    paint();
  });

08色温度トラック — 値比率を色相・彩度補間に変えて染まるトラック

値の比率をhue = HUE_COLD + (HUE_WARM-HUE_COLD)*ratioで線形補間し、毎フレーム--hue・--satを更新すると、トラック背景とつまみの色が冷たい青から暖かい橙へ物差しのように染まります。累積面積5.576%・面積最大2.603%・強度平均94.7・動いたフレーム21/23です。

色温度トラック — 値比率を色相・彩度補間に変えて染まるトラック
新しいタブで開く

08 · 色温度トラック — 値比率を色相・彩度補間に変えて染まるトラック — 計測 累積面積 5.576% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

linear-gradientCSS custom propertiespointermove

1つのpaint関数がhueとsatを同時に更新する

ratio1つからhueとsatの両方をそのフレームで計算してCSS変数に書き込むため、トラックの背景色とつまみの色が常にずれずに同じ温度を指します。

  function paint(v) {
    var ratio = (v - MIN) / (MAX - MIN);
    var hue = HUE_COLD + (HUE_WARM - HUE_COLD) * ratio;
    var sat = SAT_MIN + (SAT_MAX - SAT_MIN) * ratio;
    wrap.style.setProperty('--pct', (ratio * 100).toFixed(2));
    wrap.style.setProperty('--hue', hue.toFixed(1));
    wrap.style.setProperty('--sat', sat.toFixed(1));
    hueEl.textContent = 'hue ' + Math.round(hue);
    range.setAttribute('aria-valuenow', String(Math.round(v)));
  }

09リデュースモーション — 慣性・スプリングを切り即時に値を反映する静的代替

matchMedia('(prefers-reduced-motion: reduce)')がtrueなら慣性・オーバーシュートのループ自体を開始せず、目標値へ即座にスナップした完成状態を表示します。累積面積2.876%・面積最大1.169%・強度平均185.3・動いたフレーム21/23です。

リデュースモーション — 慣性・スプリングを切り即時に値を反映する静的代替
新しいタブで開く

09 · リデュースモーション — 慣性・スプリングを切り即時に値を反映する静的代替 — 計測 累積面積 2.876% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

prefers-reduced-motiontransitionaria-valuenow

mqの判定結果を最初の1回だけ読む

reduce変数はmq.matchesを一度読んで固定し、その値によって左パネルがスプリング追従・右パネルが即時ジャンプという2系統の描き方のどちらを使うかを最初に分けます。

  var MIN = 0, MAX = 100;
  var K = 150, C = 17;
  var TAU = 0.13;

  var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
  var reduce = mq.matches;
  var MODE_TEXT = { def: '検知: デフォルト', reduced: '検知: 動作軽減' };
  modeEl.textContent = reduce ? MODE_TEXT.reduced : MODE_TEXT.def;

  function paint(wrap, range, v) {
    var pct = (v - MIN) / (MAX - MIN) * 100;
    wrap.style.setProperty('--pct', pct.toFixed(2));
    range.setAttribute('aria-valuenow', String(Math.round(v)));
  }

この9種、実際どこに置くものか

設定画面の明るさ・音量調整、商品フィルターの価格帯指定、写真編集ツールの色補正パネル — この3つの画面がカスタムスライダーを最もよく必要とする場所です。02 スプリングオーバーシュートは値が変わるたびに手応えが欲しい設定画面に合い、05 目盛りスナップは決まった段階にだけ固定されるべきフィルター値の指定に合います。

色を扱う編集ツールでは、08 色温度トラックが値そのものを視覚的に示すので、数字を読まなくても方向を掴めます。3つの画面すべてでトラック上の位置ひとつが実際の値を決めるという点が共通しているため、百分率と実際の値を行き来するこの構造がそのまま当てはまります。

動きは計算だ — 慣性lerp・スナップ復元力・オーバーシュートの式

9項目とも値が画面上の位置に変わる式は(value - min) / (max - min) * 100の1つだけで、そこから先の手触りは4つの計算層のどれを乗せるかで決まります。

慣性lerpは速度を記録し指数減衰で追い越さない

pointermoveの間の移動距離から瞬間速度を記録し、離した瞬間からその速度をDECAY_PER_SECで毎フレーム減じるだけなので、目標を追い越して戻る跳ねは起きません。

スプリングオーバーシュートはK・Cだけで弾みが変わる

accel = -K(現在値-目標値) - C速度をオイラー積分するとKが強いほど速く戻ろうとし、Cが強いほどその戻りが早く収まる、2つの係数だけで手触りが決まる減衰振動です。

スナップ復元力はround()の後にスプリングで引き込む

round(value / step) * stepが目盛りの着地点を決め、そこへ実際に到達するのは同じスプリング(または指数減衰)の役目で、この分担が「決まった後もまだ動いている」感覚を作ります。

色温度補間は値比率をhue・satへ線形変換する

値比率をそのままhueとsatの2つの数値に線形変換して毎フレーム書き込むだけで、キーフレームを1つも使わずに連続した色の変化が生まれます。

どこで壊れるか — ブラウザ別の落とし穴

同じ計算式でも、疑似要素の対応漏れや積分の書き方1つでスライダー全体の手触りが変わってしまう落とし穴があります。

input[type=range]をdivに置き換えると起きること

コントロール全体をdivとJavaScriptの自作クリック判定に置き換えると、矢印キー操作とスクリーンリーダーの値読み上げが静かに失われ、appearance: noneはあくまで見た目を消すためだけに使う必要があります。

位置をpx値で固定すると起きること

風船ラベルやつまみの位置を目分量のpx値で固定すると、min・maxやコンテナ幅が変わるたびに手作業で測り直すことになり、--pctに計算を集約していないと保守できません。

rAFをフレーム数として数えると起きること

減衰・スプリングの係数を「1フレームにつき何%」のように決めると、同じ係数でも60fpsの端末と30fpsの端末で手触りが変わってしまうため、実際のdtに対して積分してDECAY_PER_SECのように秒単位に変換する必要があります。

06縦型スライダーで塗りを反転させると起きること

横型を-90度回転させた縦型スライダーで塗りを(100% - var(--pct))に反転させると、塗りが画面上側の断片に落ちてつまみから分離してしまうため、回転後は反転させずvar(--pct)のまま使うことをレンダリング確認の上で確定しています。

減衰と慣性係数を下げるとアクセシビリティ対応も一緒に見える

同じ計算式のまま定数を1つ動かすだけで、9種のうち3種は手触りが大きく変わります。

慣性減衰係数0.90から0.75へ — 氷から金属への手触り

03のDECAYを0.90から0.75へ下げると、氷の上のように遠くまで滑っていたつまみが手にすぐ捕まる重い金属のような感触になります。

スナップ間隔max/10からmax/20へ — 磁石感と精密さのトレードオフ

05のSTEPを(MAX-MIN)/10から(MAX-MIN)/20へ変えると目盛りが密になって精密調整に近づき、逆に間隔が広いときほど跳ねが一番生き生きします。

トラック太さ8pxから16pxへ — 精密さとタッチ対象のトレードオフ

01の$track-hを8pxから16pxへ太くすると、スライダー デザイン おしゃれを狙うより先にタッチ対象として指に馴染む余裕が生まれ、薄いトラックは逆に精密な道具のように見えます。zip内のvanillaフォルダをそのまま開けば、gkbc756yを解凍キーにしてこの3変形の元コードも一緒に確認できます。

prefers-reduced-motionでループそのものを止める仕上げ

09はmatchMediaでprefers-reduced-motionを検知した瞬間に慣性・スプリング・自動演示のループ自体を開始せず、目標値へ即座にスナップした完成状態を表示するため、空の画面や既定値のまま止まった見た目にはなりません。

スライダーを付けるとよくある質問

css レンジスライダーのつまみを描き直すと矢印キー操作は死にますか

いいえ。appearance: noneはネイティブの見た目だけを消し、input要素自体は残るため、矢印キー・Tabフォーカス・スクリーンリーダーの読み上げはそのまま動きます。疑似要素はあくまで見た目の上塗りです。

目盛りスナップの跳ねはCSSのtransitionで再現できますか

できません。transitionのイージングは目標へ一方向に近づくだけで、目標を通り過ぎて戻る動きは出ないため、accel = -K(pos-target) - Cvelocityの積分でしか跳ねは生まれません。

スライダー デザイン コピペでこの9種をそのまま使えますか

はい。zip内のvanillaフォルダのindex.htmlをそのまま開けば同じ計算層のまま動き、min・max・TAU・K・Cの定数だけを書き換えれば自分のデザインに調整できます。

もっと読む

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

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