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

スクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターン

9つのうち1つはスクロールが止まった後も移動の値が直前の速度を引き継いで少しずつ小さくなり、1つは端を超えて伸びた値が離すと元の値へ弾むように戻り、もう1つは方向が変わった瞬間に値が逆に引かれてから元の値へ戻ります。「スクロール 慣性 css」で探している方に、9つの計算方式をそのまま見せます。

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

9つとも本物のスクロール入力にだけ反応し、それ以外には反応しません。8つ(01〜05、07〜09)は本物の wheel イベントとポインタードラッグ(pointerdown/pointermove/pointerup)の両方に応え、06だけは本物の scroll イベントに応えます — 06自身が持つ自動再生ループを訪問者のスクロールと取り違えないよう、専用の autoScrolling フラグで守られています。06・08・09は矢印キーの keydown にも応え、01と03はウィンドウの resize でスクロール可能な高さを測り直します。9つのどれも Math.random で動きを決めておらず、すべて requestAnimationFrame の中で一定の定数を読むだけの決定的な計算なので、同じスクロール入力なら何度でも同じ数値が出ます。ヒーローヘッダーや固定ナビにスクロール インタラクションらしい実在感を持たせたいとき、9つとも土台として使える動きです。

9つのパターンの解剖 — どの値がいつ変わるか

なぜ5つのパターンは力を積分せず、毎フレーム一定の比率で値を減衰させるのか

01・03・05・07・08は加速度の値を別に持たず、毎フレーム速度か位置の値を一定の比率定数で掛けるかイージングするだけです — vel = FRICTION(01)、vel = COAST_FRICTION(03)、レイヤーごとの vel *= L.decay(05)、vel += (BASE_SPEED - vel) * FRICTION_PULL(07)、thumbY += (target - thumbY) * LAG(08)という形で、値自身の直前の状態だけが次のフレームの入力になります。

なぜ3つのパターンはスプリングの復元力を毎フレーム速度へ、速度を位置へ積分するのか

02・04・09は、値が目標値からどれだけ離れているかから復元力を計算し(force = -SPRING_K * (x - target) - SPRING_C * v)、その力を速度へ足し、速度を値そのものへ足すという計算を毎フレーム繰り返します。だからこそ3つとも目標値を一度通り過ぎてから戻ってくることができ、02のヘッダーの伸び・04の区間位置・09のナビの拡大率は、どれも目標に収まる前に一度その先まで揺れます。

なぜ1つのパターンはスプリングの復元力ではなく、一定の重力加速度を積分するのか

06だけは位置に関係なく毎フレーム一定の値(GRAVITY)を速度へ足し続けます — 目標から離れるほど強く引かれるスプリングとは違い、落下する物体が一定の加速度で加速していくのと同じ計算です。位置の値が FLOOR_Y に届いたときだけ速度を反転させ RESTITUTION の分だけ縮めるので、一続きの落下が一度のスプリング振動ではなく跳ねながら収まる落下感に変わります。

パターン別コード

01スクロール慣性コースティング — スクロールが止まった後も移動の値が直前の速度を引き継ぎ、少しずつ小さくなっていく方式

指を離した瞬間の速度の値を引き継ぎ、移動の値が少しずつ小さくなって止まります。長いリストページや画像ギャラリーのスクロールでよく使われる方式で、指やホイールを離した直後に急に止まるのではなく、それまでと同じ方向へ動き続けるように見えるのが特徴です。

スクロール慣性コースティング — スクロールが止まった後も移動の値が直前の速度を引き継ぎ、少しずつ小さくなっていく方式
新しいタブで開く

01 · スクロール慣性コースティング — スクロールが止まった後も移動の値が直前の速度を引き継ぎ、少しずつ小さくなっていく方式 — 計測 累積面積 20.972% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FRICTIONMIN_VELOCITYWHEEL_SCALE

数値が示すこと

23枚の追跡フレームのうち01が動くのは20枚、累積面積は20.972%、平均強度は94.7です — この9つの中でも面積が大きい部類に入るのは、コースティングがトラック全体を滑らせる動きだからです。

  var WHEEL_SCALE = 0.55;

  var pos = 0, vel = 0, raf = null, maxScroll = 0;

  function measure() {
    maxScroll = Math.max(0, track.scrollHeight - viewport.clientHeight);
  }
  function clamp(v) { return Math.max(-maxScroll, Math.min(0, v)); }
  function paint() { track.style.transform = 'translateY(' + pos.toFixed(1) + 'px)'; }

  function stopCoast() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
  function coastTick() {
    vel *= FRICTION;
    pos = clamp(pos + vel);
    if (pos === 0 || pos === -maxScroll) { vel = 0; }
    paint();
    if (Math.abs(vel) < MIN_VELOCITY) { raf = null; return; }
    raf = requestAnimationFrame(coastTick);
  }
  function startCoast(v) {
    vel = v;
    if (reduce) { pos = clamp(pos + vel * 6); vel = 0; paint(); return; }
    stopCoast();
    raf = requestAnimationFrame(coastTick);
  }

02オーバースクロール弾性 — スクロールの端を超えると、伸びた値が戻る方式

端を超えて引くと伸びた値が、離すと元の値へ弾むように戻ります。モバイルの上部ヘッダーやアプリ風ランディングページでよく使われる方式で、指を離した瞬間にヘッダーが伸びてから戻る、オーバー スクロール バウンスらしい感触が価値になります。

オーバースクロール弾性 — スクロールの端を超えると、伸びた値が戻る方式
新しいタブで開く

02 · オーバースクロール弾性 — スクロールの端を超えると、伸びた値が戻る方式 — 計測 累積面積 21.332% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PULL_RESISTSPRING_KSPRING_C

数値が示すこと

23枚の追跡フレームすべてで02は動き、累積面積21.332%、平均強度65.3です — 23枚全部でヘッダーが自分のオーバーシュートを戻し続けている数字です。

  function springTick() {
    var force = -SPRING_K * (stretch - 1) - SPRING_C * sVel;
    sVel += force * (1 / 60) * 0.06;
    stretch += sVel;
    paint();
    if (Math.abs(stretch - 1) < 0.003 && Math.abs(sVel) < 0.003) {
      stretch = 1; sVel = 0; paint(); raf = null; return;
    }
    raf = requestAnimationFrame(springTick);
  }
  function release() {
    if (reduce) { stretch = 1; sVel = 0; paint(); return; }
    stopSpring();
    raf = requestAnimationFrame(springTick);
  }
  function pullTo(dist) {
    var d = Math.max(0, dist);
    stretch = Math.min(MAX_STRETCH, 1 + Math.sqrt(d) / PULL_RESIST);
    paint();
  }

03スクロール速度ブラー — スクロール速度の値が大きいほど、ぼかしの値がそれに比例して大きくなる方式

速度の値が上がるほどぼかしの値が大きくなり、止まるとぼかしの値は0に戻ります。ギャラリーの背景画像やヒーローセクションの背景でよく使われる方式で、トラックを動かす同じ横方向の値がぼかしの強さも決めるため、速いフリックほど動きのぼやけとして見えます。

スクロール速度ブラー — スクロール速度の値が大きいほど、ぼかしの値がそれに比例して大きくなる方式
新しいタブで開く

03 · スクロール速度ブラー — スクロール速度の値が大きいほど、ぼかしの値がそれに比例して大きくなる方式 — 計測 累積面積 20.219% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

VELOCITY_TO_BLURMAX_BLURCOAST_FRICTION

数値が示すこと

23枚のうち03が動くのは18枚、累積面積20.219%、平均強度105.2です — 9つの中で平均強度が最も高いのは、位置とぼかしの両方が同時に動く場面が多いからです。

  function paint() {
    track.style.transform = 'translateX(' + pos.toFixed(1) + 'px)';
    track.style.filter = blur > 0.05 ? 'blur(' + blur.toFixed(2) + 'px)' : 'none';
  }
  function stopLoop() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
  function loopTick() {
    vel *= COAST_FRICTION;
    pos = clamp(pos + vel);
    if (pos === 0 || pos === -maxScroll) { vel = 0; }
    var target = Math.min(MAX_BLUR, Math.abs(vel) * VELOCITY_TO_BLUR);
    blur += (target - blur) * BLUR_SMOOTH;
    paint();
    if (Math.abs(vel) < MIN_VELOCITY && blur < 0.05) { blur = 0; paint(); raf = null; return; }
    raf = requestAnimationFrame(loopTick);
  }
  function startLoop(v) {
    vel = v;
    if (reduce) { pos = clamp(pos + vel * 6); vel = 0; blur = 0; paint(); return; }
    stopLoop();
    raf = requestAnimationFrame(loopTick);
  }

04区間スナップスプリング — 離されたスクロール位置の値が最も近い区間の値へ、スプリング減衰しながら落ち着く方式

離された位置の値が近い区間の値の周りで揺れ、収まって止まります。フルページのセクション構成やスライド型の紹介ページでよく使われる方式で、途中のどこで指を離しても、中途半端な位置ではなく最も近い区間できれいに止まります。

区間スナップスプリング — 離されたスクロール位置の値が最も近い区間の値へ、スプリング減衰しながら落ち着く方式
新しいタブで開く

04 · 区間スナップスプリング — 離されたスクロール位置の値が最も近い区間の値へ、スプリング減衰しながら落ち着く方式 — 計測 累積面積 20.706% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SECTION_HSPRING_KSPRING_C

数値が示すこと

23枚すべてで04は動き、累積面積20.706%、平均強度88.7です — スプリングが目標の区間を一度通り過ぎては戻る動きを、ほぼ毎フレーム繰り返している数字です。

  function clampFree(v) { return Math.max(-maxScroll, Math.min(0, v)); }
  function paint() { track.style.transform = 'translateY(' + pos.toFixed(1) + 'px)'; }
  function stopSpring() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
  function nearestTarget() { return clampFree(Math.round(pos / SECTION_H) * SECTION_H); }

  function springTick(target) {
    var force = -SPRING_K * (pos - target) - SPRING_C * vel;
    vel += force * (1 / 60) * 0.06;
    pos += vel;
    paint();
    if (Math.abs(pos - target) < 0.4 && Math.abs(vel) < 0.4) {
      pos = target; vel = 0; paint(); raf = null; return;
    }
    raf = requestAnimationFrame(function () { springTick(target); });
  }
  function snap() {
    var target = nearestTarget();
    if (reduce) { pos = target; vel = 0; paint(); return; }
    stopSpring();
    raf = requestAnimationFrame(function () { springTick(target); });
  }

05レイヤー別減衰パララックス — スクロールが止まった後も、レイヤーごとに異なる減衰率で移動の値が縮み続ける方式

レイヤーごとの減衰率の値が異なり、止まった後も移動の値がそれぞれ違う速さで小さくなります。ヒーローの背景レイヤーやブランドストーリーページでよく使われる方式で、ドラッグを離した瞬間からレイヤー同士が目に見えてばらけて見えます。

レイヤー別減衰パララックス — スクロールが止まった後も、レイヤーごとに異なる減衰率で移動の値が縮み続ける方式
新しいタブで開く

05 · レイヤー別減衰パララックス — スクロールが止まった後も、レイヤーごとに異なる減衰率で移動の値が縮み続ける方式 — 計測 累積面積 9.958% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WRAPdecayspeed

数値が示すこと

23枚すべてで05は動き、累積面積9.958%、平均強度83.6です — 01〜04より小さいのは、レイヤーごとにドラッグ量の一部分しか動かないからです。

  function clampWrap(v) { var m = v % WRAP; if (m > 0) { m -= WRAP; } return m; }
  function paint() {
    for (var i = 0; i < layers.length; i++) {
      layers[i].el.style.transform = 'translateX(' + layers[i].pos.toFixed(1) + 'px)';
    }
  }
  function stopLoop() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
  var inputActive = false;
  function loopTick() {
    var moving = false;
    for (var i = 0; i < layers.length; i++) {
      var L = layers[i];
      if (!inputActive) { L.vel *= L.decay; if (reduce) { L.vel = 0; } }
      L.pos = clampWrap(L.pos + L.vel);
      if (Math.abs(L.vel) > 0.02) { moving = true; }
    }
    paint();
    if (reduce || (!moving && !inputActive)) { raf = null; return; }
    raf = requestAnimationFrame(loopTick);
  }
  function ensureLoop() { if (raf === null) { raf = requestAnimationFrame(loopTick); } }
  function setInput(v) {
    for (var i = 0; i < layers.length; i++) { layers[i].vel = v * layers[i].speed; }
    ensureLoop();
  }

06落下定着 — スクロールで表示されると要素の位置の値が重力の値で落下し、床の値で跳ねてから定着する方式

表示された要素の位置の値が落下し、床の値で跳ね返ることを繰り返しながら小さくなっていきます。カード型のリストや商品グリッドセクションでよく使われる方式で、スクロールで視界に入った瞬間にカードが落ちて弾みながら定着する様子がそのまま見えます。

落下定着 — スクロールで表示されると要素の位置の値が重力の値で落下し、床の値で跳ねてから定着する方式
新しいタブで開く

06 · 落下定着 — スクロールで表示されると要素の位置の値が重力の値で落下し、床の値で跳ねてから定着する方式 — 計測 累積面積 22.223% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRAVITYRESTITUTIONSTART_Y

数値が示すこと

23枚のうち06が動くのは14枚、累積面積22.223%、平均強度47.4です — 9つの中で累積面積が最大なのは、落ちて跳ねるカードが実際に縦方向の距離を移動するからです。

  function dropTick(card, y, vel) {
    vel += GRAVITY;
    y += vel;
    if (y >= FLOOR_Y) {
      y = FLOOR_Y;
      vel = -vel * RESTITUTION;
      if (Math.abs(vel) < STOP_VEL) {
        card.style.transform = 'translateY(0px)';
        card.dataset.done = '1';
        return;
      }
    }
    card.style.transform = 'translateY(' + y.toFixed(1) + 'px)';
    requestAnimationFrame(function () { dropTick(card, y, vel); });
  }
  function settle(card) {
    if (card.dataset.done) { return; }
    card.style.opacity = '1';
    if (reduce) { card.style.transform = 'translateY(0px)'; card.dataset.done = '1'; return; }
    card.dataset.done = '1';
    dropTick(card, START_Y, 0);
  }

07摩擦減速 — 入力の値が途切れた後、速度の値が摩擦の分だけ徐々に落ちていく方式

入力が途切れると速度の値が一定の摩擦の値ずつ落ち、やがて元の速度の値に戻ります。ロゴのマーキーやニュースティッカーバナーでよく使われる方式で、ドラッグやホイールは基準の速さから値をずらすだけで、帯自体が完全に止まることはありません。

摩擦減速 — 入力の値が途切れた後、速度の値が摩擦の分だけ徐々に落ちていく方式
新しいタブで開く

07 · 摩擦減速 — 入力の値が途切れた後、速度の値が摩擦の分だけ徐々に落ちていく方式 — 計測 累積面積 5.444% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BASE_SPEEDFRICTION_PULLCHIP_W

数値が示すこと

23枚すべてで07は動き、累積面積5.444%、平均強度105.0です — 入力が何もなくても帯自身の基準速度が毎フレーム動き続けている数字です。

  var CHIP_W = 56, GAP = 10, SET_COUNT = 6;
  var WRAP = SET_COUNT * (CHIP_W + GAP);
  var BASE_SPEED = -0.9;
  var FRICTION_PULL = 0.06;
  var pos = 0, vel = BASE_SPEED;
  var inputActive = false;
  var raf = null;

  function clampWrap(v) { var m = v % WRAP; if (m > 0) { m -= WRAP; } return m; }
  function paint() { track.style.transform = 'translateX(' + pos.toFixed(1) + 'px)'; }
  function loopTick() {
    if (reduce) { raf = null; return; }
    if (!inputActive) { vel += (BASE_SPEED - vel) * FRICTION_PULL; }
    pos = clampWrap(pos + vel);
    paint();
    raf = requestAnimationFrame(loopTick);
  }
  function ensureLoop() { if (raf === null && !reduce) { raf = requestAnimationFrame(loopTick); } }

08遅延追従 — 追従する値が実際のスクロールの値にすぐ追従せず、遅延の値だけ遅れて追いかける方式

追従する値が実際のスクロールの値より遅延の値だけ遅れて追いかけ、少しずつ追いつきます。自作スクロールバーやドキュメント型のコンテンツページでよく使われる方式で、速くスクロールしたときだけ、つまみが本物のスクロール位置から遅れて見えます。

遅延追従 — 追従する値が実際のスクロールの値にすぐ追従せず、遅延の値だけ遅れて追いかける方式
新しいタブで開く

08 · 遅延追従 — 追従する値が実際のスクロールの値にすぐ追従せず、遅延の値だけ遅れて追いかける方式 — 計測 累積面積 1.782% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LAGSNAP_EPSrowH

数値が示すこと

23枚のうち08が動くのは9枚、累積面積1.782%、平均強度108.0です — 9つの中で面積が最小なのは、動くのがつまみ自身だけで、リストや背後のトラックは一切動かないからです。

  // BD19 — レール・つまみの実測ピクセルは getBoundingClientRect で測る(推測値禁止)。
  var railRect = rail.getBoundingClientRect();
  var railH = railRect.height;
  var thumbH = Math.max(28, railH * (viewport.clientHeight / (rowH * rowCount)));
  thumb.style.height = thumbH.toFixed(1) + 'px';

  function paintTrack() { track.style.transform = 'translateY(-' + scrollPos.toFixed(1) + 'px)'; }
  function targetThumbY() { return maxScroll <= 0 ? 0 : (scrollPos / maxScroll) * (railH - thumbH); }
  function paintThumb() { thumb.style.transform = 'translateY(' + thumbY.toFixed(1) + 'px)'; }
  function loopTick() {
    var target = targetThumbY();
    if (reduce) { thumbY = target; paintThumb(); raf = null; return; }
    thumbY += (target - thumbY) * LAG;
    if (Math.abs(target - thumbY) < SNAP_EPS) {
      thumbY = target; paintThumb(); raf = null; return;
    }
    paintThumb();
    raf = requestAnimationFrame(loopTick);
  }

09ラバープル — スクロール方向が変わった瞬間、値がゴムのように引かれてから戻る方式

方向が変わった瞬間、値が逆に引かれ、元の値へ弾むように戻ります。上部のナビゲーションバーやモバイルアプリ風ヘッダーでよく使われる方式で、一定方向のスクロール中は静かなまま、訪問者が向きを変えた瞬間だけ反応します。

ラバープル — スクロール方向が変わった瞬間、値がゴムのように引かれてから戻る方式
新しいタブで開く

09 · ラバープル — スクロール方向が変わった瞬間、値がゴムのように引かれてから戻る方式 — 計測 累積面積 1.796% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MIN_VELKICKSPRING_C

数値が示すこと

23枚のうち09が動くのは9枚、累積面積1.796%、平均強度44.3です — 方向が反転した直後の数フレームだけ動き、スプリングが収まればまた静止する数字です。

  function navTick() {
    var force = -SPRING_K * (navScale - 1) - SPRING_C * navVel;
    navVel += force * 0.016;
    navScale += navVel * 0.016;
    if (reduce) { navScale = 1; navVel = 0; paintNav(); navRaf = null; return; }
    if (Math.abs(navScale - 1) < 0.003 && Math.abs(navVel) < 0.02) {
      navScale = 1; navVel = 0; paintNav(); navRaf = null; return;
    }
    paintNav();
    navRaf = requestAnimationFrame(navTick);
  }
  function ensureNavLoop() { if (navRaf === null) { navRaf = requestAnimationFrame(navTick); } }
  function kickNav(sign) {
    if (reduce) { return; }
    navVel += sign * KICK;
    ensureNavLoop();
  }
  function applyVel(vel) {
    if (lastVel !== 0 && vel !== 0 && (lastVel > 0) !== (vel > 0) && Math.abs(lastVel) > MIN_VEL && Math.abs(vel) > MIN_VEL) {
      kickNav(vel > 0 ? 1 : -1);
    }
    if (Math.abs(vel) > 0.1) { lastVel = vel; }
  }

値がパターンを変える基準 — 減衰・質量・境界の基準

なぜ01のFRICTION定数は滑走の長さを決め、始まり方は決めないのか

FRICTIONが0.93のときは、指を離してから半拍ほどで速度の値が収まり、短い距離だけ滑ります。0.99まで上げると、まったく同じ速さで放しても数秒がかりで進み続け、短い滑りが長い慣性走行に変わります — 滑り出す瞬間の速さそのものはどちらも同じです。

なぜ06のRESTITUTION定数は跳ねる回数を決め、最初の落下の高さは決めないのか

RESTITUTIONが0.42のときは、床で一、二度低く跳ねてすぐ止まります。0.75まで上げると、同じ高さから落としても何度も高く弾み続けてから収まります — 最初の落下そのものはどちらも変わりません。

なぜ09のSPRING_K定数は跳ね返る回数を決め、跳ね返る向きは決めないのか

SPRING_Kが260のときは、方向が変わるたびにナビバーが短く硬く一度だけ弾みます。90まで下げると、同じ衝撃でもバネが柔らかくなり、何度も揺れ戻ってから静止します — どちら向きに弾むかは、方向転換そのものの向きだけで決まります。

この9つは、すでに扱った3つの記事とは別の計算軸に立っています — 動きの値がどこから来るかという軸です。【コピペOK】スクロール アニメーション CSS のみ 9選(002)は animation-timeline: view()/scroll() というブラウザが計算する進行度だけで動き、JavaScript のループも、フレームをまたいで持ち越す速度の値も一切ありません — この9つが毎フレーム読み書きする vel(や navVel・sVel)とは違う仕組みです。引っ張って更新 スマホ 9選 — 引いた距離で変わる更新のかたち(353)も毎フレーム実際にドラッグした距離を読みますが、9つのうちいくつかはその距離がしきい値を越えた瞬間に本物の更新処理を実行します — 画面の外まで届くこの状態変化は、値がただ元の位置へ収まっていくだけのこの9つには一つもありません。ボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通り(357)は02・04・09と同じ accel→vel→位置というスプリング積分ループを回していますが、357の9つはどれも一度の押し込みと解放という区切りの中で完結するのに対し、01のコースティングする vel・05のレイヤーごとの decay・07の BASE_SPEED へ戻ろうとする値は、終わりのないスクロールセッションに答え続けます。9つのファイルをまとめて1本のアーカイブとして落とし、自分の設定に差し替えて試してから使ってください。

どこで壊れるか — 連続スクロール・低スペック端末・モーション削減

コースティングの途中で方向を反転するとどうなるか

01と03はホイールの新しいイベントが届いた瞬間に stopCoast()/stopLoop() を呼び、その回のイベントの deltaY(または deltaX)だけから速度の値を新しく計算し直します。動いているコースティングに強く逆方向へフリックすると、それまで残っていた速度の値は混ざらずに捨てられ、値は反転を滑らかに経由せず新しい曲線へ飛び移ります。

非常に長く連続してスクロールし続けるとどうなるか

9つのどれも、スクロールし続けた時間そのものが積み上がるような状態は持っていません。速度・位置・減衰のどの値も、その時点の入力だけから毎フレーム計算し直すかクランプするかのどちらかなので、1秒のスクロールでも1時間のスクロールでも、同じ入力に対しては毎フレーム同じ数値が出ます。

prefers-reduced-motion がオンのときどうなるか

9つのうち5つ(02・04・06・08・09)は reduce が true になった瞬間、計算済みの正確な最終値へそのまま飛びます — 伸びや拡大率の値なら1、位置の値なら最も近い区間か遅延の目標、カードなら着地した状態で、途中を一切アニメーションしません。01と03はその代わりに、コースティングをおよそ6フレーム分進めたのと同じ値(vel * 6)だけ位置を進め、同じ処理の中で速度を0にします — これは本物のコースティングが止まる場所を正確に計算するのではなく、おおよそで見積もっているだけです。05は入力が止まった瞬間にその場で凍りつきます — reduce のときは各レイヤーの速度を0にするだけで、飛び先の目標値を一切計算しないからです。そして07が9つの中で最も厳格で、reduce が true の間は ensureLoop() 自体がループの開始を拒むため、マーキーは開始位置から一度も動きません。パスワード azjysfxp で解凍すれば、上で挙げた定数はすべて同じ index.html の中にそのまま入っています。

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

項目 定数 値 何が変わるか
01 慣性コースティング FRICTION 0.93 → 0.99 放す瞬間の速さは同じでも、速度の値が半拍で収まるか数秒がかりで進み続けるかが変わり、短い滑りが長い慣性走行になります
06 落下定着 RESTITUTION 0.42 → 0.75 同じ高さから同じように落ちても、一、二度低く跳ねて止まるか、何度も高く弾んでから収まるかが変わります
09 ラバープル SPRING_K 260 → 90 同じ方向転換の衝撃でも、ナビバーが短く硬く一度だけ弾むか、柔らかく何度も揺れ戻ってから静止するかが変わります

9つとも同じ role="application" と、パネルの内容を説明する aria-label を持っています。06・07・08・09には tabindex="0" もありますが、実際に keydown に応えるのは06・08・09だけで、06と08は矢印キーでのスクロール、09は方向転換の値を蹴る操作に使われ、07の tabindex はフォーカスできるようにするだけでキー操作そのものは持ちません。9つのどれも aria-live 領域を持たないため、ここでの値の変化はすべて視覚的な効果にとどまり読み上げられることはなく、9つとも前の章で説明した prefers-reduced-motion: reduce を確認しているので、OS側でこれをオンにしている訪問者にはループそのものが一切動きません。

FAQ

9つのパターンに物理演算ライブラリは必要ですか、それとも素の JavaScript で十分ですか

素の JavaScript で十分です — 9つとも requestAnimationFrame のループの中で、位置や速度、いくつかの定数といった少数の数値を計算しているだけで、物理エンジンはおろかブラウザ標準のスクロール・ポインター・アニメーションフレーム系 API 以外は何も読み込んでいません。

タッチパッド スクロール 慣性のような OS 標準の挙動と、この9つの計算は同じものですか

同じではありません — それは OS やブラウザ自身が制御する挙動で、ページ側の JavaScript からは基本的に関与できません。この9つは、その標準の挙動とは別に、ページ自身が requestAnimationFrame の中で位置と速度の値を計算し直すことで似た感触を作り出しています。

06だけスクロールのフレームごとではなく表示時に発火するのはなぜですか

06は scroll イベントではなく本物の IntersectionObserver に配線されているからです — カードは0.6の可視率のしきい値をそのオブザーバーが超えた瞬間にだけ落下を始め、スクロールの1ピクセルごとには反応しません。だからこそ06は、一度定着したカードを二度と再計算しない、9つの中で唯一の項目でもあります。

Sources: MDN — scroll イベント · MDN — overscroll-behavior · MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — IntersectionObserver

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

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