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

css スクロールバー デザイン 9パターン — 慣性と反発で動く

css スクロールバー デザインでは、指先がトラックの端に触れた一瞬、つまみが少し押し出されて跳ね返る。3番はその瞬間、慣性の値で数フレーム余分に滑らせ、4番は反発の値で跳ね返らせ、9番はその二つの値を一つのつまみの上で重ねる。

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

9つのカスタムスクロールバーは、どれだけつまみが動くかで並んでいます

以下の9つのデモはすべて、480×300の8枚カードリストの上で同じ自作ハンドル——transform: translateY() で位置を、height で長さを決める <div> で、ブラウザ既定のスクロールバーの箱ではありません——を動かします。並び順には理由があります。1番は他の8つが拡張していく基本のつまみ、2番から5番まではそれぞれ一つずつ反応する値(光沢位置・離脱速度・押し出し距離・グラデーション停止点)を足していき、6番は同じ計算を横トラックに向け、7番と8番は位置ではなく時間と比率を読み、9番はここまでの値を3つ重ねて一つのハンドルに乗せます。

01太さ値がトラックを満たす基本のつまみ

このパターンに必要な数値は太さ値と角丸値の二つだけです——THUMB_MIN_PX はリストが長くなってもつまみがドラッグできない大きさまで縮まないよう下限を保証し、syncThumb() はスクロールのたびにトラック自身の clientHeight からつまみの高さと位置を計算し直します。

太さ値がトラックを満たす基本のつまみ
新しいタブで開く

01 · 太さ値がトラックを満たす基本のつまみ — 計測 累積面積 33.433% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

THUMB_MIN_PXKEY_STEP_PXsyncThumb
  function syncThumb() {
    var trackLen = track.clientHeight;
    var total = viewport.scrollHeight;
    var visible = viewport.clientHeight;
    var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
    var ms = maxScroll();
    var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
    var thumbPos = (trackLen - thumbLen) * ratio;
    thumb.style.height = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
  }

  viewport.addEventListener('scroll', syncThumb);

実測: 累積面積33.4%、平均強度56.0、23/23 フレームで動いた。

02カーソルが乗ると光沢が通り過ぎるつまみ

GLOSS_SWEEP_MS は光の反射レイヤーがつまみの長さを一度だけ通り過ぎるのにかかる時間を決め、triggerGloss() はそのスイープを発火させていいかどうかを判定するゲートです——スイープの途中では再始動を拒み、prefers-reduced-motion が設定されていれば最初から拒みます。これはまさに、さりげないホバー演出と目障りな演出を分ける css スクロールバー カスタマイズ の細部です。

カーソルが乗ると光沢が通り過ぎるつまみ
新しいタブで開く

02 · カーソルが乗ると光沢が通り過ぎるつまみ — 計測 累積面積 33.401% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GLOSS_SWEEP_MStriggerGlosssyncThumb
  function triggerGloss() {
    if (reduce) return;
    if (sweeping) return;
    sweeping = true;
    gloss.classList.add('is-sweeping');
  }

実測: 累積面積33.4%、平均強度43.9、23/23 フレームで動いた。

03手を離しても慣性値の分だけ滑り続けるつまみ

このパターンが動かす数値は SCROLLBAR_INERTIA_DECAY ひとつです。ドラッグしていたつまみを離した瞬間の速度値が、アニメーションフレームごとにこの減衰係数だけ掛け合わされていくため、ポインターが離れた瞬間にぴたりと止まるのではなく、あと数フレームだけ滑り続けます。

手を離しても慣性値の分だけ滑り続けるつまみ
新しいタブで開く

03 · 手を離しても慣性値の分だけ滑り続けるつまみ — 計測 累積面積 33.617% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SCROLLBAR_INERTIA_DECAYcoastStepVEL_EPS
  thumb.addEventListener('pointermove', function (e) {
    if (!dragging) return;
    var trackLen = track.clientHeight;
    var thumbLen = thumb.clientHeight;
    var moveRange = Math.max(1, trackLen - thumbLen);
    var scale = maxScroll() / moveRange;
    var frameDeltaPx = (e.clientY - lastMoveY) * scale;
    vel = frameDeltaPx;
    lastMoveY = e.clientY;
    var totalDeltaPx = (e.clientY - dragStartY) * scale;
    viewport.scrollTop = clamp(dragStartScroll + totalDeltaPx, 0, maxScroll());
  });
  function coastStep() {
    if (Math.abs(vel) <= VEL_EPS) { vel = 0; return; }
    vel = vel * SCROLLBAR_INERTIA_DECAY;
    viewport.scrollTop = clamp(viewport.scrollTop + vel, 0, maxScroll());
  }

実測: 累積面積33.6%、平均強度57.3、23/23 フレームで動いた。

04端に当たると押し出されて跳ね返るつまみ

BOUNDARY_REBOUND_PX はつまみがトラックの端をどれだけ押し出すかを決める値で、その押し出し値は springBack() によって SPRING_DECAY ずつ毎フレーム縮んでいき、小さな閾値を下回った時点で「もう動かすものがない」と関数自身が報告します。

端に当たると押し出されて跳ね返るつまみ
新しいタブで開く

04 · 端に当たると押し出されて跳ね返るつまみ — 計測 累積面積 13.787% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BOUNDARY_REBOUND_PXspringBackSPRING_DECAY
  thumb.addEventListener('pointermove', function (e) {
    if (!dragging) return;
    var trackLen = track.clientHeight;
    var thumbLen = thumb.clientHeight;
    var moveRange = Math.max(1, trackLen - thumbLen);
    var scale = maxScroll() / moveRange;
    var rawTarget = dragStartScroll + (e.clientY - dragStartY) * scale;
    var ms = maxScroll();
    viewport.scrollTop = clamp(rawTarget, 0, ms);
    if (reduce) {
      overshoot = 0;
    } else if (rawTarget < 0) { overshoot = clamp(rawTarget, -BOUNDARY_REBOUND_PX, 0); }
    else if (rawTarget > ms) { overshoot = clamp(rawTarget - ms, 0, BOUNDARY_REBOUND_PX); }
    else { overshoot = 0; }

実測: 累積面積13.8%、平均強度144.1、12/23 フレームで動いた。

05スクロール位置値で色が移り変わるつまみ

applyPositionColor() は生のスクロール位置と描画の間に挟まる計算レイヤーです。syncThumb() がつまみの高さのために計算している0から1の同じ比率値をそのまま受け取り、つまみのグラデーションとトラックの発光が両方参照するCSSカスタムプロパティに渡すので、読み手は css スクロールバー 色 だけでおおよその現在地を推測できます。

スクロール位置値で色が移り変わるつまみ
新しいタブで開く

05 · スクロール位置値で色が移り変わるつまみ — 計測 累積面積 33.326% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

applyPositionColorsyncThumbTHUMB_MIN_PX
  function syncThumb() {
    var trackLen = track.clientHeight;
    var total = viewport.scrollHeight;
    var visible = viewport.clientHeight;
    var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
    var ms = maxScroll();
    var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
    var thumbPos = (trackLen - thumbLen) * ratio;
    thumb.style.height = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
    applyPositionColor(ratio);
  }

実測: 累積面積33.3%、平均強度59.8、23/23 フレームで動いた。

06カード数値に合わせて伸びる横トラック

CARD_COUNT がスクロール可能な行の幅そのものを決め、syncThumbX() は1番と同じ比率計算をX軸に回転させただけの関数です——clientWidth・scrollWidth・scrollLeft が縦方向の対になる値を置き換え、つまみは translateY() の代わりに translateX() で動きます。

カード数値に合わせて伸びる横トラック
新しいタブで開く

06 · カード数値に合わせて伸びる横トラック — 計測 累積面積 26.656% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

maxScrollXsyncThumbXCARD_COUNT
  function syncThumbX() {
    var trackLen = track.clientWidth;
    var total = viewport.scrollWidth;
    var visible = viewport.clientWidth;
    var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
    var ms = maxScrollX();
    var ratio = ms > 0 ? viewport.scrollLeft / ms : 0;
    var thumbPos = (trackLen - thumbLen) * ratio;
    thumb.style.width = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateX(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
  }

実測: 累積面積26.7%、平均強度55.1、23/23 フレームで動いた。

07止まると透明度値が下がり消えるオーバーレイつまみ

OVERLAY_HIDE_DELAY_MS は最後のスクロールイベントからつまみが完全に見えたままでいる時間で、その時間を過ぎると OVERLAY_FADE_MS が透明度を1から0まで一気にではなく一定の幅で下げていきます。新しいスクロールイベントが来れば、経過時間値も透明度値もその場でゼロに戻ります。

止まると透明度値が下がり消えるオーバーレイつまみ
新しいタブで開く

07 · 止まると透明度値が下がり消えるオーバーレイつまみ — 計測 累積面積 21.918% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

OVERLAY_HIDE_DELAY_MSOVERLAY_FADE_MSsyncThumb
    if (scrolledFlag) { lastActiveT = t; scrolledFlag = false; }
    var elapsedMs = (t - lastActiveT) * 1000;
    var opacity;
    if (elapsedMs <= OVERLAY_HIDE_DELAY_MS) {
      opacity = 1;
    } else if (reduce) {
      opacity = 0;
    } else {
      opacity = clamp(1 - (elapsedMs - OVERLAY_HIDE_DELAY_MS) / OVERLAY_FADE_MS, 0, 1);
    }
    track.style.opacity = opacity.toFixed(3);

実測: 累積面積21.9%、平均強度78.6、13/23 フレームで動いた。

08つまみ長さ値が残り分量をそのまま示すスクロールバー

他の全パターンがつまみの長さを決めるのに使っているのと同じ高さの比率値が、ここではそのまま平易な数字も動かします。remaining がスクロール比率を反転させてパーセント表示に変え、バッジに書き込むので、縮んでいくつまみの長さと減っていくバッジの数字が同じ話を二通りに語ります。

つまみ長さ値が残り分量をそのまま示すスクロールバー
新しいタブで開く

08 · つまみ長さ値が残り分量をそのまま示すスクロールバー — 計測 累積面積 27.411% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

THUMB_MIN_PXsyncThumbremaining
  function syncThumb() {
    var trackLen = track.clientHeight;
    var total = viewport.scrollHeight;
    var visible = viewport.clientHeight;
    var lengthRatio = clamp(visible / total, 0, 1);
    var thumbLen = clamp(trackLen * lengthRatio, THUMB_MIN_PX, trackLen);
    var ms = maxScroll();
    var scrollRatio = ms > 0 ? viewport.scrollTop / ms : 0;
    var thumbPos = (trackLen - thumbLen) * scrollRatio;
    thumb.style.height = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(scrollRatio * 100)));
    var remaining = Math.round((1 - scrollRatio) * 100);
    badge.textContent = '残り' + clamp(remaining, 0, 100) + '%';
  }

実測: 累積面積27.4%、平均強度48.0、23/23 フレームで動いた。

09複数の値が一つのつまみの上で共に反応するヒーロースクロールバー

paintThumb() は1番の syncThumb() に一行だけ足したものです——4番のばね計算が生み出す押し出し値を、スクロール比率と一緒にそのままつまみの位置へ折り込みます——同じファイルの中では2番の光沢スイープのゲートと3番の慣性減衰の定数も並行して動き続けていて、三つの別々の値の連鎖が結局は一つのつまみを同時に動かすことになります。

複数の値が一つのつまみの上で共に反応するヒーロースクロールバー
新しいタブで開く

09 · 複数の値が一つのつまみの上で共に反応するヒーロースクロールバー — 計測 累積面積 36.332% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GLOSS_SWEEP_MSSCROLLBAR_INERTIA_DECAYBOUNDARY_REBOUND_PX
  function paintThumb() {
    var trackLen = track.clientHeight;
    var total = viewport.scrollHeight;
    var visible = viewport.clientHeight;
    var thumbLen = clamp(trackLen * (visible / total), THUMB_MIN_PX, trackLen);
    var ms = maxScroll();
    var ratio = ms > 0 ? viewport.scrollTop / ms : 0;
    var thumbPos = (trackLen - thumbLen) * ratio + overshoot;
    thumb.style.height = thumbLen.toFixed(1) + 'px';
    thumb.style.transform = 'translateY(' + thumbPos.toFixed(1) + 'px)';
    thumb.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
  }

実測: 累積面積36.3%、平均強度66.4、23/23 フレームで動いた。

実際にどれだけ動いたか — 9つのつまみの測定

項目 累積面積 平均強度 動いたフレーム
01 基本つまみ角丸 33.4% 56.0 23/23
02 ホバー光沢スイープ 33.4% 43.9 23/23
03 ドラッグ慣性コースト 33.6% 57.3 23/23
04 境界弾性オーバーシュート 13.8% 144.1 12/23
05 グラデーション位置シフト 33.3% 59.8 23/23
06 横トラックギャラリー 26.7% 55.1 23/23
07 フローティングオーバーレイ自動非表示 21.9% 78.6 13/23
08 つまみ比率残量バッジ 27.4% 48.0 23/23
09 ヒーローシーン3値重ね 36.3% 66.4 23/23

9つの中で平均強度が最も高いのは4番の144.1、動いたフレーム数が最も少ないのも4番の12/23です——反発はスクロール比率が0か1に触れた直後の短い区間でしか跳ねないので、変化する画素はサイクルの大半に薄く広がる1・3・5・6・8・9番とは違い、短く強く動きます。

値がつまみの反応を変える場所 — 慣性減衰・境界反発・オーバーレイ遅延

9つのパターンのうち、重みの大半を負っている定数は3つです。SCROLLBAR_INERTIA_DECAY(3番と9番)は速度に掛かるフレームごとの乗数なので、動いているつまみを減速させることしかできず、止まっているつまみを押し出すことはできません。BOUNDARY_REBOUND_PX(4番と9番)はスクロール比率が0か1に達したちょうどそのフレームに一度だけ加わる固定ピクセル距離で、その後 SPRING_DECAY によって次のフレームから巻き戻されます。OVERLAY_HIDE_DELAY_MS(7番)だけは毛色が違い、位置ではなく透明度を動かし、1へ向かって減衰するのではなくスクロールのたびにゼロへ戻ります。9つのどれも scrollbar-width や scrollbar-color には触れていません——ここにあるつまみはすべてスクリプトが位置を決める本物の <div> で、その理由はこの二つのネイティブプロパティが既定の箱の色と太さを変えるだけで、光沢・反発・フェードタイマーを付け足す仕組みを持たないからです。9つ全部が共有するもう一つの定数 THUMB_MIN_PX は、これら3つとは違う狭い理由で存在します。リストが十分長くなると syncThumb() の中の visible / total がポインターでは確実につかめないほど小さな比率まで縮んでいくため、THUMB_MIN_PX は真の比率がどれだけ小さくなろうとつまみの高さに下限をかけ、長さの正確さを少し犠牲にしてでもドラッグできる大きさを守ります。

どこで壊れるか — モーション低減設定・低スペック端末・タッチ入力

zipを開く前に知っておく落とし穴は .is-demo です。9つすべてが既定でシード固定の擬似スクロールサイクルをループさせているのは、まだ誰も触れていないページでもつまみが生きているように見せるためで、つまみへの最初の本物の pointerdown・keydown・ドラッグがそのクラスを外し、実際のスクロール位置へ制御を戻します——操作した瞬間、デモはリハーサルされた動きを読者自身の動きに明け渡します。この切り替えはテストの仕方にも関わります——固定した読み込み状態だけで9つを判断すると必ずリハーサル済みのサイクルしか見えず、本物のドラッグ・コースト・反発の計算を見る唯一の方法はつまみをクリックかタブで操作して実際に動かすことで、これはこのページの下の測定表が1つの数値を記録する前に必ず通った手順と同じです。prefers-reduced-motion: reduce はさらに深く切り込みます——2番と9番は光沢スイープをそもそも発火させず、7番はフェードせずに透明度をいきなりゼロへ落とし、3番・4番・9番のコーストと反発の計算そのものは動き続けます(到達性が壊れるわけではありません)が、動きとして目に見える余分なフレームを足すのをやめます——スクリーンリーダーやキーボードだけの読者にとってこの違いはそもそも意味を持ちません、role="scrollbar" と aria-valuenow は見た目に関係なく同じ頻度で更新され続けるからです。低スペック端末が最も重く感じるのは9番で、光沢・慣性・反発の3つを同じつまみの上で毎フレーム計算し直しているのに対し、1番や8番のように定数一つで済むつまみはネイティブなスクロールバーがもともと払っているスクロールリスナーの分以外、ほとんど追加コストがありません——1つより3つの反応値を毎フレーム計算し直すほうが単純に処理量が多いだけで、端末の速さそのものとは別の話です。タッチ入力はマウスと同じ pointerdown/pointermove/pointerup のリスナーを通っているため、指でつまみをドラッグする動きはカーソルでの操作と変わらず、タッチ画面での短いドラッグでも vel と overshoot にはマウスドラッグと同じ数値が渡るので、3番・4番・9番のコーストと反発はどちらの入力でも同じように起こります。9つの index.html/style.scss すべてを開けるwn4b2gqqがこの節にあるのは意図的なことで、ここまで読めば上の定数はページの上の数字ではなく、自分で実際に変えられる値に変わります。

3つのバリエーションとアクセシビリティに残る課題

バリエーション 変更した値 変わること
つまみの慣性減衰値を調整する SCROLLBAR_INERTIA_DECAY 0.92 → 0.98 値を小さくすると手を離した直後につまみはすぐ止まり、値を大きくすると離した後もしばらく滑り続けるはずだ
境界反発距離の値を調整する BOUNDARY_REBOUND_PX 12 → 32 値を小さくすると端に当たってもわずかに押し出されて戻り、値を大きくすると大きく押し出されて弾むように戻るはずだ
オーバーレイの消失遅延値を調整する OVERLAY_HIDE_DELAY_MS 600 → 1800 値を小さくするとスクロールが止まるとすぐにつまみが薄れ、値を大きくするとしばらく残ってからゆっくり薄れるはずだ

3つのバリエーションのどれも、上のアクセシビリティ経路には触れません——role="scrollbar"・aria-valuenow・矢印キーからPage Up/Down・Home・Endまでのキーボード一式は、SCROLLBAR_INERTIA_DECAY が0.92でも0.98でも同じ配線のままです。ポインターに反応する css スクロールバー カスタマイズ であっても、最終的にはキーボードにも答えなければならないからで、9つのつまみはどれも矢印キーの操作一つで .is-demo を外し、実際のスクロール位置に制御を渡します。

css スクロールバー デザインは既定のスクロールバーと何が違うか?

既定のスクロールバーはレンダリングエンジンが描いて更新してくれる箱で、スタイルシートが触れる面は scrollbar-width と scrollbar-color の2つのプロパティだけです。同じ箱を狙ったエンジン接頭辞付きセレクターを使っても、できるのは色と太さの入れ替えまでです。上の9つはそのネイティブな箱を隠し(scrollbar-width: none と対になるエンジン接頭辞付き擬似セレクター)、本物のスクリプト付き <div> に置き換えるので、光沢・反発・グラデーションの色・フェードタイマーがどれも値で動かせるものになります——ネイティブな箱にはそのどれにもフックがありません。代わりに払う代償も本物で、syncThumb() はスクロールのたびに自分で走らなければならず、role="scrollbar" と aria-valuenow も、置き換えた箱と同じだけの到達性を保つために手で付ける必要があります。

使う前によくある質問にまとめて答えます

ネイティブなスクロールバーを隠すとキーボードでのスクロールが壊れますか?

いいえ——scrollbar-width: none と対になるエンジン接頭辞付き擬似セレクターで隠しても、消えるのは描かれた箱だけで、要素そのものはスクロール可能なままです。それぞれのデモは独自の keydown リスナーも持っていて、矢印キー・Page Up/Down・Home・Endでつまみを直接動かせます。これはブラウザがスクロールコンテナ自体に対してもともと行っていることに上乗せされます。

なぜ4番のつまみはトラックの実際の端を越えて動くのですか?

BOUNDARY_REBOUND_PX は、スクロール比率が0か1に達したちょうど1フレームだけ、つまみの位置に自分自身を足します——これは意図された行き過ぎであってバグではなく、springBack() が次のフレームからその余分な距離をゼロへ引き戻し始めます。

この9つは1ページにまとめて組み込めますか、それとも独立していますか?

各パターンは自分専用の定数と自分専用の demoTick() ループを持つ独立した index.html/style.scss の組で、9つのどれか1つだけを既存のページに落とし込んでも残り8つを引き込むことはありません。9番だけが光沢・慣性・反発という3つの値の連鎖をあらかじめ組み合わせていますが、それも他の8つから読み込むのではなく1つの独立したファイルの中で完結しています。

MDNのscrollbar-colorガイドは、この9つが置き換えているネイティブな2つのプロパティを解説しており、prefers-reduced-motionは上のすべてのデモが最初のフレームの前に確認しているメディアクエリを扱っています。同じ慣性減衰の考え方を1つのつまみではなくページ全体の運動量に当てはめた編としてはスクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターンが構造的にいちばん近い兄弟編で、css スクロール アニメーション 9選 — 区間値で切り替えるは scroll リスナーの代わりに宣言的なタイムラインでスクロール位置を読んでいて、ここの5番の手動での比率計算と見比べる価値があります。そしてスクロール インジケーター 9選 — 現在地がすぐわかるは目的がいちばん近い従兄弟編です——どちらの系列もスクロール比率を目に見えるものに変えますが、読書進捗バーはスクロール可能な要素の外に置かれるのに対し、上のつまみはどれも要素自身のスクロールバーを置き換えています。

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

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