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

レンジスライダー ui 9種 — ハンドルが交差しない計算まで

レンジスライダー ui 9種のうち、1つは2つのハンドルが最小間隔より内側で重ならないよう計算で防ぎ、1つは値が近づくと2つの値の吹き出しが1つに合体し、1つはトラックの奥に棒グラフを敷いて分布を一緒に示します。

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

レンジスライダーとは、ひとつのトラック上で最小値と最大値を同時に決める2ハンドルの入力パーツのことで、下の9種はどれも同じポインタ操作とキーボード操作の土台の上に、名前の付いた小さな計算関数を一層だけ重ねています。交差防止クランプ・区間だけ塗るフィル・吹き出し合体・中央ドラッグでの平行移動・Tab交互操作・ヒストグラム背景・10刻みスナップ・弾性プッシュ・モーション比較の9種で、どれも <input type="range"> を2本並べただけでは足りない部分を、それぞれ1つの関数で補っています。

デモ

2つのハンドルが交差しない範囲スライダー
新しいタブで開く

01 · 2つのハンドルが交差しない範囲スライダー — 計測 累積面積 3.058% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GAP=14role=slideraria-valuetext

上の01は setLo/setHi の中のクランプだけで2つのハンドルの間隔を保っています。残り8種も、それぞれのコードブロックの直上にデモが並んでいます。

9種の構造 — 計算が一層どこに走るか

# パターン 累積面積 平均強度 動いたフレーム
01 交差防止クランプ 3.058% 176.2 23中22
02 区間だけ塗るフィル 3.797% 101.0 23中21
03 吹き出し合体 8.158% 111.0 23中21
04 中央ドラッグ平行移動 4.107% 55.4 23中17
05 Tab交互操作 3.223% 145.1 23中21
06 ヒストグラム背景 6.338% 87.0 23中21
07 10刻みスナップ 5.147% 129.7 23中21
08 弾性プッシュ 3.027% 159.9 23中21
09 モーション比較 5.484% 129.3 23中21

9種の中で画面をいちばん広く塗り替えるのは03(累積8.158%)です。2つの吹き出しを1つに合体させる瞬間、両方の値ラベルと合体後のラベルを一緒に描き直すためです。逆にいちばん狭いのは08(3.027%)で、スプリングが1フレームごとにハンドルをわずかしか動かさず、吹き出しも棒グラフも描き直さないからです。平均強度がもっとも高いのは01(176.2)で、動いたフレームも9種の中で最多(23中22)です。ポインタが動くたびに毎回クランプの判定を行い、is-blocked クラスの切り替えとハンドルの移動が同時に起きるためです。反対に04は平均強度がもっとも低く(55.4)、動いたフレームも最少(23中17)です。中央をドラッグしても2つのハンドルと1つの塗り区間しか描き直さず、どちらかの端に触れた瞬間にドラッグそのものが止まるからです。

9種を「一層の計算が実際に何を読んでいるか」で分けると、3つの系統に整理できます。

ハンドル同士の関係を縛る系統

01・04・08は、ハンドル自身の位置ではなく2つのハンドルの間隔を縛ります。01の setLo/setHi は相手側の値から GAP を引いた(足した)値でクランプします。04の shiftBoth は幅(hi - lo)を固定したまま両端を同じ delta だけ動かします。08はさらに踏み込み、要求された間隔が MIN_GAP を下回りそうになると相手側の目標値ごと押し出し、その視覚的な追従をスプリングに任せます。

getBoundingClientRect で実測する系統

02・03・07は、%の値をトラックの実際の描画幅から px に変換します。02は塗り幅を%と一緒に px でも表示します。03は2つの値吹き出しの実際の画面上の間隔を px で測り、MERGE_PX と比較してから合体を決めます。07は各目盛りの left を px でトラック実測幅から計算し直すため、トラック幅が変わっても目盛りがずれません。

ドラッグのたびに計算し直さない系統

05・06・09は、ドラッグそのものには触れない状態を1つ持っています。05は focusin/focusout からどちらのハンドルが「アクティブ」かを追跡し、ポインタ操作とは独立しています。06は固定の HEIGHTS 配列から棒グラフを一度だけ作り、描画のたびに [lo, hi] に入っているかだけを再判定します。09は共有する1組の値を、トランジションありのパネルとトランジションなしのパネルの2枚に描き分けており、片方の描画がもう片方に依存しません。

パターンごとのコード

以下のブロックはすべて、そのパターンの index.html が実際に実行している関数そのものです — 変数名も順序もそのまま、省略はありません。

012つのハンドルが交差しない範囲スライダー

最小値のハンドルを最大値側へ寄せても、決めておいた最小の間隔より近づかずその位置で止まります。最小・最大価格を同時に決めるフィルターに向いています。

2つのハンドルが交差しない範囲スライダー
新しいタブで開く

01 · 2つのハンドルが交差しない範囲スライダー — 計測 累積面積 3.058% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GAP=14role=slideraria-valuetext
  // 計算層: 最小値が最大値 - GAP を超えないよう、最大値が最小値 + GAP を下回らないよう固定する
  function setLo(n) {
    var blocked = document.getElementById('track');
    v.lo = Math.max(MIN, Math.min(n, v.hi - GAP));
    lo.classList.toggle('is-blocked', n > v.hi - GAP);
    paint();
  }
  function setHi(n) {
    v.hi = Math.min(MAX, Math.max(n, v.lo + GAP));
    hi.classList.toggle('is-blocked', n < v.lo + GAP);
    paint();
  }

022つのハンドルの間だけ色が満ちる範囲スライダー

トラック全体ではなく低い方と高い方のハンドルの間の区間だけ色が満ち、ハンドルを動かすとその区間の両端が位置に合わせて動きます — 224 とは異なる実演です。224(range-slider-dual-thumb-fill)は値の更新と単純な塗りのみですが、こちらは getBoundingClientRect 実測トラック幅で塗り幅を px 換算して示し、自動再生のスイープ位相まで加えます。選んだ区間がひと目でわかる必要がある統計フィルターに向いています。

2つのハンドルの間だけ色が満ちる範囲スライダー
新しいタブで開く

02 · 2つのハンドルの間だけ色が満ちる範囲スライダー — 計測 累積面積 3.797% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRectpxOutaria-valuetext
  // 計算層: 2つの値をトラック塗りつぶしの%に変換し、実測したトラック幅(getBoundingClientRect)で塗り幅を px にも換算する
  function paint() {
    lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
    fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
    var sweepPct = v.lo + (v.hi - v.lo) * v.phase;
    var trackPct = v.hi - v.lo === 0 ? 0 : (sweepPct - v.lo) / (v.hi - v.lo) * 100;
    sweep.style.left = trackPct + '%';
    lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
    lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
    loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
    var r = track.getBoundingClientRect();
    pxOut.textContent = String(Math.round(r.width * (v.hi - v.lo) / 100));
  }

03値が近づくと2つの吹き出しが1つに合体する範囲スライダー

各ハンドルに値の吹き出しが付いてきますが、2つの値が近づくと吹き出し同士が重ならないよう1つの吹き出しに両方の値を並べて表示します。狭い区間を精密に選ぶ予約日フィルターに向いています。

値が近づくと2つの吹き出しが1つに合体する範囲スライダー
新しいタブで開く

03 · 値が近づくと2つの吹き出しが1つに合体する範囲スライダー — 計測 累積面積 8.158% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MERGE_PX=46getBoundingClientRectrole=slider
  // 計算層: 2つの値吹き出しの実際の画面上の間隔(px)をトラック実測幅から求め、MERGE_PX より狭くなったら1つに合体させる
  function paint() {
    lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
    fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
    bLo.style.left = v.lo + '%'; bHi.style.left = v.hi + '%';
    bMerged.style.left = (v.lo + v.hi) / 2 + '%';
    lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
    lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
    bLo.textContent = String(v.lo); bHi.textContent = String(v.hi);
    bMerged.textContent = v.lo + ' – ' + v.hi;
    var r = track.getBoundingClientRect();
    var gapPx = r.width * (v.hi - v.lo) / 100;
    var merged = gapPx < MERGE_PX;
    root.classList.toggle('is-merged', merged);
  }

04中央をドラッグすると2つのハンドルが一緒に動く範囲スライダー

満ちた区間の中央をつかんで引くと、2つのハンドルの間隔はそのままに区間全体が一緒に動き、端に触れるとその場で止まります。選んだ期間を幅を保ったまま前後に動かすスケジュールフィルターに向いています。

中央をドラッグすると2つのハンドルが一緒に動く範囲スライダー
新しいタブで開く

04 · 中央をドラッグすると2つのハンドルが一緒に動く範囲スライダー — 計測 累積面積 4.107% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

shiftBoth()getBoundingClientRectrole=slider
  // 計算層: 幅(hi-lo)はそのままに2つの値を同じ delta だけ平行移動させ、端に触れた時点でその場に止める
  function shiftBoth(deltaPct) {
    var w = v.hi - v.lo;
    var nlo = v.lo + deltaPct;
    nlo = Math.max(MIN, Math.min(nlo, MAX - w));
    v.lo = nlo; v.hi = nlo + w;
    paint();
  }

05Tab で2つのハンドルを交互に操作する範囲スライダー

Tab キーで低い方と高い方のハンドルにフォーカスが交互に移り、方向キーを押すと今フォーカスのあるハンドルだけが一段ずつ動き、最小間隔は保たれます。マウスなしで値を正確に合わせるフォームフィルターに向いています。

Tab で2つのハンドルを交互に操作する範囲スライダー
新しいタブで開く

05 · Tab で2つのハンドルを交互に操作する範囲スライダー — 計測 累積面積 3.223% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

focusinaria-live=politerole=slider
  // 計算層: focusin/focusout で今どちらのハンドルにフォーカスがあるかを追跡し、そのハンドルだけを方向キーで動かす
  function setActive(which) {
    root.classList.toggle('is-active-lo', which === 'lo');
    root.classList.toggle('is-active-hi', which === 'hi');
  status.textContent = which === 'lo' ? 'Tab · 最小値にフォーカス' : 'Tab · 最大値にフォーカス';
  }
  lo.addEventListener('focusin', function () { setActive('lo'); });
  hi.addEventListener('focusin', function () { setActive('hi'); });

06ヒストグラム背景の上で区間を選ぶ範囲スライダー

トラックのすぐ奥に値の区間ごとの棒グラフが敷かれていて、ハンドルで選んだ区間が全体の分布のどこに当たるかを棒の高さで一緒に示します。価格帯別の件数分布を一緒に見せる検索フィルターに向いています。

ヒストグラム背景の上で区間を選ぶ範囲スライダー
新しいタブで開く

06 · ヒストグラム背景の上で区間を選ぶ範囲スライダー — 計測 累積面積 6.338% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HEIGHTS.forEach()role=slideraria-valuenow
  HEIGHTS.forEach(function (h, i) {
    var bar = document.createElement('div');
    bar.className = 'ds__bar';
    bar.style.left = (i / HEIGHTS.length * 100) + '%';
    bar.style.width = (100 / HEIGHTS.length * 0.7) + '%';
    bar.style.height = h + '%';
    hist.appendChild(bar);
    bars.push({ el: bar, center: (i + 0.5) / HEIGHTS.length * 100 });
  });

  // 計算層: 棒の実際の中心位置(%)が [lo, hi] の区間に入っているかで強調するかどうかを決める
  function paintBars() {
    bars.forEach(function (b) {
      b.el.classList.toggle('is-in', b.center >= v.lo && b.center <= v.hi);
    });
  }

0710 刻みの目盛りに揃う範囲スライダー

ハンドルを動かした瞬間に10刻みの目盛りへ丸められて止まり、低い方と高い方の値は互いに越えないよう常に比較して固定されます。決まった単位でしか値を選べない数量フィルターに向いています。

10 刻みの目盛りに揃う範囲スライダー
新しいタブで開く

07 · 10 刻みの目盛りに揃う範囲スライダー — 計測 累積面積 5.147% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STEP=10getBoundingClientRectrole=slider
  // 計算層: 目盛りを%ではなく、トラックの実測ピクセル幅(getBoundingClientRect)から位置を計算し直して配置する
  function layoutTicks() {
    var r = track.getBoundingClientRect();
    ticks.forEach(function (t) {
      var x = r.width * (t.value - MIN) / (MAX - MIN);
      t.el.style.left = Math.round(x) + 'px';
    });
  }
  function snap(n) { return Math.max(MIN, Math.min(MAX, Math.round(n / STEP) * STEP)); }

08最小間隔を守るために弾性で押される範囲スライダー

2つのハンドルの間隔が最小値より狭くなろうとすると反対側のハンドルも一緒に押し出されて間隔を守り、画面上の位置は質量減衰スプリング物理で目標値を追いかけてわずかにオーバーシュートしながら追従します。間隔が狭くなりすぎてはいけない人数範囲フィルターに向いています。

最小間隔を守るために弾性で押される範囲スライダー
新しいタブで開く

08 · 最小間隔を守るために弾性で押される範囲スライダー — 計測 累積面積 3.027% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MIN_GAP=12requestAnimationFramerole=slider
  function springFrame(ts) {
    if (reduce) { pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual(); return; }
    if (last === null) last = ts;
    var dt = Math.min(0.032, (ts - last) / 1000); last = ts;
    ['lo', 'hi'].forEach(function (k) {
      var s = pos[k], t = target[k];
      var force = -K * (s.v - t) - DAMP * s.vel;
      s.vel += force * dt; s.v += s.vel * dt;
    });
    paintVisual();
    requestAnimationFrame(springFrame);
  }

09モーション削減と通常モーションを並べて比較する範囲スライダー

同じ2つのハンドル値を2つのパネルに同時に描画し、左パネルは位置の変化にトランジションがかかりますが右のプレビューはトランジションなしで即座に移動するので、モーション削減時の結果を通常の動きと並べて比較できます。動きに敏感なユーザーのためのアクセシビリティ確認用プレビューに向いています。

モーション削減と通常モーションを並べて比較する範囲スライダー
新しいタブで開く

09 · モーション削減と通常モーションを並べて比較する範囲スライダー — 計測 累積面積 5.484% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

aria-hidden=trueprefers-reduced-motionrole=slider
  // 計算層: 同じ1つの状態を2つのパネルに同時に描画するが、右側のプレビューは transition を最初から持たないため、
  // 「モーション削減」利用者が見る即時移動の結果を常に一緒に示す(実際のOS設定とは無関係な比較用)
  function paint() {
    lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
    fillA.style.left = v.lo + '%'; fillA.style.width = (v.hi - v.lo) + '%';
    loB.style.left = v.lo + '%'; hiB.style.left = v.hi + '%';
    fillB.style.left = v.lo + '%'; fillB.style.width = (v.hi - v.lo) + '%';
    lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
    lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
    loOutA.textContent = String(v.lo); hiOutA.textContent = String(v.hi);
    loOutB.textContent = String(v.lo); hiOutB.textContent = String(v.hi);
  }

値を変えると UI が変わる場所 — 最小間隔・スナップ目盛り間隔・吹き出し合体距離

01 — GAP

この定数は setLo と setHi が常に保つ最小距離を決めます。ドラッグできる範囲そのものではなく、どちらのハンドルも下回れない床の値だけを決めています。

07 — STEP

これは snap() が Math.round(n / STEP) * STEP でハンドルの値を丸める刻み幅です。目盛り線自体の px 幅ではなく、値がどれだけ粗く/細かく止まれるかだけを決めています。

03 — MERGE_PX

これは paint() が2つの値吹き出しを1つに切り替える前に比較する画面上の px 間隔です。値そのものではなく、吹き出しが2つのラベルではなく1つのラベルとして読めるようになる距離だけを決めています。

変形 変えた値 感触
GAP 14 → 6 最小ハンドル間隔が14から6に縮む クランプが効くまで2つのハンドルがほぼ重なる位置まで寄せられ、ほぼ幅ゼロの区間も選べるようになる
STEP 10 → 25 スナップ刻みが10から25に広がる 止まれる位置がぐっと減り、連続的というより粗く区切られた感触でドラッグが進む
MERGE_PX 46 → 18 合体しきい値が46pxから18pxに縮む まだ隙間があるうちに合体していたのが、ほぼ触れ合うまで2つの吹き出しが分かれたままになる

どこで壊れるか — アクセシビリティ・モーション削減・ハンドルの重なり

9種すべてが同じ土台を共有しています。各ハンドルは本物の <button role="slider"> で、aria-valuemin・aria-valuemax・aria-valuenow・aria-valuetext を毎回の描画で同期し、aria-label にはデモ自身が使うのと同じ文言ファイルから取った「最小値」「最大値」が入り、tabindex="0" によって通常の文書順で Tab が届きます。Home・End はハンドルをそれぞれの境界まで動かし、方向キーは1単位ずつ動かします。matchMedia('(prefers-reduced-motion: reduce)') はどのパターンでも同じもの — デモを自動で動かすオートプレイのループ — を止めます。それぞれの SCSS は、そのオートプレイ状態のときだけ left/width のトランジションを外すので、モーション削減を設定している訪問者はページを開いた瞬間からプリセットの切り替えを動きなしで見ます。9種のうち8種は、そのあとドラッグしたり方向キーを押したりすると通常どおりトランジションがかかります。人が意図して選んだ値の動きまでは、この設定が抑える対象ではないからです。

  • 01 — サムとフィルのトランジションだけが外れ、オートプレイのプリセットが切り替わっている間だけ効きます。
  • 02 — サム・フィル・オートプレイのスイープマーカーのトランジションが一緒に外れます。
  • 03 — サム・フィル・合体吹き出しのトランジションが一緒に外れ、合体そのものもモーション削減下ではアニメーションしなくなります。
  • 04 — サムとフィルのトランジションが外れ、shiftBoth によるオートプレイ中の平行移動が滑らずに瞬時に切り替わります。
  • 05 — サムとフィルのトランジションが外れる一方、どちらのハンドルにフォーカスがあるかを知らせる role="status" の告知はどちらの設定でも変わりません。テキストの更新は CSS のトランジションではないためです。
  • 06 — サム・フィル・ヒストグラムの強調バーのトランジションが一緒に外れます。
  • 07 — サムとフィルのトランジションが外れる一方、目盛りの位置はもともとアニメーションしていません。layoutTicks() は一度と resize 時にしか再計算しないためです。
  • 08 — 唯一の例外: ルールがオートプレイ状態に限定されておらず、トランジションが常に外れています。syncReduced() がドラッグやキーボード操作で動かしたハンドルもスプリングのオーバーシュートを待たず目標値へ直接スナップさせるためです。
  • 09 — このパターンの目的そのものが差を並べて見せることなので、条件付きで外れるものは何もありません。右パネルは常にトランジションなし、左パネルは常にトランジションありで、OS設定を変えなくても両方を比較できます。

パスワード nrfh573p で解いた zip を最初から最後まで読むのが、9枚のスタイルシートを並べて、モーション削減のためにどの1行を削っているのかを確かめるいちばん早い方法です。

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

上の表にすでに確定した3つの変形 — GAP・STEP・MERGE_PX — を載せています。どれも定数を1つだけ入れ替え、リスナー・クランプ・ARIA 属性は元のパターンとまったく同じままです。

レンジスライダー html の実装例はどこで見られる?

「レンジスライダー デザイン」の実例を探すなら、決済前の価格帯の絞り込みや日程フィルターがいちばんわかりやすい場所です。上の9種はどれもプレーンなボタンとプレーンなポインタ・キーボードのリスナー、そして1つずつの小さな計算関数だけでできていて、zip の中にそのままフォームへ貼り付けられる形で入っています。

FAQ

なぜ07は resize でスナップ位置を再計算するのに、01は再計算が要らないのですか?

07の目盛りは layoutTicks() が getBoundingClientRect() で読み取ったトラックの px 幅から配置しているため、resize でその幅が変わると再計算しないと目盛りがラベルからずれてしまいます。01のクランプはハンドルがもともと使っている0〜100の%スケールだけで完結しているので、トラックの px 幅をそもそも知る必要がありません。

08のスプリングのオーバーシュートで間隔が MIN_GAP を下回ることはありますか?

いいえ。スプリングが動く前に target.lo/target.hi の確定値がすでに MIN_GAP でクランプされているため、実データが最小間隔を割ることはありません。オーバーシュートが影響するのは、その目標値を追いかける画面上の位置 pos.lo.v/pos.hi.v だけなので、見た目としては1〜2フレーム行き過ぎたり戻ったりして見えても、実際の値が床を越えることはありません。

この作りのレンジスライダー js をそのまま本番のフォームに使っても安全ですか?

クランプ・キーボード操作・ARIA まわりのロジックはそのまま移して使えます。デモのオートプレイ用プリセットタイマーを、フォームがすでに持っている実際の最小・最大の状態に差し替えれば、関数の残りの部分は変える必要がありません。唯一このデモが意図的に省いているのはフォーム送信用の name/value ペアなので、その結びつけだけは本番で自分で加えてください。

出典: MDN — role="slider" · MDN — Element.getBoundingClientRect() · MDN — prefers-reduced-motion · MDN — requestAnimationFrame() · MDN — Window.matchMedia()

関連記事: css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種 · カレンダー 選択 UI 9選 — コピペOK · 絞り込み ui9選 — パネルスライドからチップ削除まで

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

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