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

無限スクロールローディング UIパターン9選 — スピナーから仮想ウィンドウまで

9つのうち1つは残りスクロール距離の値からローディング表示の不透明度を求め直し、1つは上に追加された高さの値の分だけスクロール位置を補正して画面がずれないようにし、もう1つは見えているインデックス範囲の値だけを残し他は描きません。無限スクロールとは、この9つのように値を求め直し続ける仕組みのことです。

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

9つとも本物のサーバーとは通信していません。行も、まとまりも、再試行も、種を固定したローカル生成器とsetTimeoutによる遅延の演技だけで動いており、fetchもXMLHttpRequestも一度も呼ばれません。それでも9つとも訪問者の実際の操作に反応します——01・02・03・05・06・07・08・09は本物のscrollイベントに反応し(wheelとtouchstartも同時に登録されていて、触れた瞬間にデモ自身の自動再生プレビューから操作権を返します)。04だけはclickに反応します。スクロール位置を持たないボタンには、そもそも反応すべき位置の値がないからです。

9つのパターンの解剖 — 値がいつ求め直されるか

9つは、スクロール位置・要求のタイミング・経過時間のどれを値に変えるかで3つに分かれます。この層を素通りすると、無限スクロール サイトの実装がスクロールバーに貼り付いたスピナーで終わってしまいます。

スクロールのたびに値をその場で求め直す3つ

01・03・08は、他の何かを待たずscrollイベントが起きるたびに値を求め直します。01のrecomputeFadeは毎回getBoundingClientRect()でビューとセンチネルの隙間を測り、THRESHOLD_PXとの比で0〜1の進行値を作ってそのままスピナーの不透明度に書き込みます。03も毎回動きますが、比較する側の値——スクロール速度から作った基準線の距離——を先に求め直してから、センチネルがそこを越えたかを見ます。08のrenderWindowはscrollTopから単一の割合ではなく開始・終了のインデックス対を求め直します。仮想化されたリストには「どれだけ進んだか」を表す一つの数字が存在しないからです。

要求の前後だけで値を求め直す3つ

02・04・07は、スクロールのたびにではなく要求1回につき1回だけ値を求め直します。02のskeletonCountForはstartLoadが始まった瞬間だけ動き、残りの画面高さを1行の高さで割ってプレースホルダーの行数を決めます。04のmorphToはクリックが始まった瞬間と新しい項目が届いた瞬間だけ動き、固定サイズではなくラベルの実測幅から目標の幅を求め直します。07は古いメッセージを挿入する前後でscrollHeightを測り、その差をsetTimeoutが解決したあとに一度だけscrollTopへ足します。まだ変わっていない高さを途中で補正しても意味がないからです。

経過時間や完了の数から値を求め直す3つ

05・06・09も本物のscrollイベントに反応しますが、求め直す値はスクロール距離と無関係です。05のtryRequestはセンチネルの位置に関係なく、performance.now()と直前の要求時刻の差をCOOLDOWN_MSと比べます。06は残り項目の値が0になった瞬間から一切求め直さなくなります。これはスクロール位置ではなく完了の数が計算そのものを止めているということです。09のrunCountdownは要求が失敗したあと、経過時間だけを頼りにrequestAnimationFrameのループでカウントダウンの値を毎瞬間求め直します。

パターン別コード

01下部スピナーフェード — 残りスクロール距離の値から不透明度を計算するローディング表示

リストの終わりに近づいた状態になると、残りスクロール距離の値を基準に不透明度の値を求め直してローディング表示が徐々に濃くなり、新しい項目が届いた状態になるとその値は再び0に戻ります。

下部スピナーフェード — 残りスクロール距離の値から不透明度を計算するローディング表示
新しいタブで開く

01 · 下部スピナーフェード — 残りスクロール距離の値から不透明度を計算するローディング表示 — 計測 累積面積 33.365% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

THRESHOLD_PXrecomputeFadegetBoundingClientRect

数字が示すもの

23フレーム中23フレームで動き、累積面積33.37%・平均強度33.9を記録しています。スピナーの不透明度がほぼ毎回のスクロールで求め直され、そのたびに再描画されているためです。THRESHOLD_PXはrecomputeFadeが完全な不透明度に達するまでの猶予ピクセル数を決め、getBoundingClientRectを使うことで余白や枠線が変わっても距離の値がずれません。

  function recomputeFade() {
    var viewBottom = view.getBoundingClientRect().bottom;
    var sentinelTop = sentinel.getBoundingClientRect().top;
    var remaining = sentinelTop - viewBottom;
    var progress = 1 - Math.min(Math.max(remaining / THRESHOLD_PX, 0), 1);
    ring.style.opacity = String(progress);
    if (remaining <= 0 && !loading && rowCount < MAX_ROWS) {
      startLoad();
    }
    hint.textContent = (remaining <= 0 && rowCount >= MAX_ROWS)
      ? 'さらにスクロールすると最初に戻ります'
      : '一番下までスクロールすると次のかたまりを読み込みます';
  }

02スケルトン行の追加 — ビューポート高さの値からプレビュー行数を計算するリスト

次の一覧を読み込む状態になると、残りの画面高さの値を1行の高さの値で割って先に見せる行数を計算し、実際のデータが届いた状態になるとその数の枠が内容に変わります。

スケルトン行の追加 — ビューポート高さの値からプレビュー行数を計算するリスト
新しいタブで開く

02 · スケルトン行の追加 — ビューポート高さの値からプレビュー行数を計算するリスト — 計測 累積面積 34.218% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

skeletonCountFor--staggerROW_H

数字が示すもの

23フレーム中21フレームで動き、累積面積34.22%は9つの中で最大です。プレースホルダーの行が一度にまとめて描かれるため、1行ずつ現れる他のパターンより面積が広がります。平均強度は42.1です。ROW_HはskeletonCountForが余った高さを行数に変える割り算の分母で、行ごとに--staggerという固有のカスタムプロパティも渡されるため、シマー(光の帯)のずれが行によって少しずつ変わり、全行が同時に光るのを防いでいます。

  // 計算層: 残っている画面高さの値を1行の高さの値で割って先に見せるスケルトン行の数を
  // 求める——画面が大きいほど、スクロールの余裕が大きいほどより多く点滅する。
  function skeletonCountFor() {
    var remainingViewport = view.getBoundingClientRect().bottom - sentinel.getBoundingClientRect().top;
    var visible = Math.max(remainingViewport, view.clientHeight);
    return Math.max(2, Math.ceil(visible / ROW_H));
  }

  function startLoad() {
    if (loading || batches >= MAX_BATCHES) { return; }
    loading = true;
    track.setAttribute('aria-busy', 'true');
    var count = skeletonCountFor();

03読み込み開始の基準線 — スクロール速度の値から先読み開始地点を計算するリスト

スクロール速度が速くなった状態になると、画面端からどれだけ前もって開始地点を引くかの値を求め直し、その分早く次の一覧を呼び出し、速度が遅くなった状態になるとその値は再び小さくなります。

読み込み開始の基準線 — スクロール速度の値から先読み開始地点を計算するリスト
新しいタブで開く

03 · 読み込み開始の基準線 — スクロール速度の値から先読み開始地点を計算するリスト — 計測 累積面積 33.015% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

recomputeThresholdSPEED_GAINperformance.now

数字が示すもの

23フレーム中23フレームで動き、累積面積33.02%・平均強度48.8を記録しています。基準線の値を表示するバッジがセンチネルの点と一緒に毎回再描画されるためです。recomputeThresholdはperformance.now()の差から px/ms の速度を求め、SPEED_GAINをかけてから上限で丸めます。速く指を滑らせるほど、ゆっくり読み進めるより明らかに早いタイミングで次の一覧が呼ばれます。

  // 計算層: 直前のスクロールイベントとの移動距離の値を経過時間の値で割って速度(px/ms)を求め、
  // その速度に比例して基準線の距離の値を求め直す——速く動かすほど画面端に着く前に早く次の
  // 一覧を呼び出す。
  function recomputeThreshold() {
    var now = performance.now();
    var dt = Math.max(now - lastTime, 1);
    var speed = Math.abs(view.scrollTop - lastTop) / dt;
    lastTop = view.scrollTop;
    lastTime = now;
    threshold = Math.min(BASE_THRESHOLD_PX + speed * SPEED_GAIN, MAX_THRESHOLD_PX);
    badge.textContent = 'しきい線 ' + Math.round(threshold) + 'px';
  }

04もっと見るボタンのモーフィング — 残り項目数の値で幅が変わるボタン

もっと見るの要求が始まった状態になると、ボタンの幅の値が待機中の表示に合わせて求め直され、次の項目が届いた状態になると残り件数の値に応じて元の幅か完了表示に戻ります。

もっと見るボタンのモーフィング — 残り項目数の値で幅が変わるボタン
新しいタブで開く

04 · もっと見るボタンのモーフィング — 残り項目数の値で幅が変わるボタン — 計測 累積面積 2.625% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

morphToWIDTH_LERPrequestAnimationFrame

数字が示すもの

23フレーム中10フレームだけで動き、累積面積2.63%は9つの中で最小です。ボタン1つの変形はリスト全体の再描画より面積がずっと狭いためです。一方で平均強度は70.7と2番目に高く、変わるピクセルがボタンの枠の中に密集しています。morphToはgetBoundingClientRect()でラベルの実測幅を測ってから目標幅を決め、WIDTH_LERPはrequestAnimationFrameのループが現在の幅を目標へ毎フレームどれだけ引き寄せるかの係数で、瞬間移動ではなく徐々に近づけています。

  // 計算層: ラベル文字列が変わるたびに実際のレンダー幅(getBoundingClientRect)を測り、
  // その値+余白を目標幅とする。毎フレーム現在の幅を目標へ24%ずつ引き寄せるlerpで
  // ボタンを変形させる。
  function morphTo(text) {
    label.textContent = text;
    var natural = label.getBoundingClientRect().width;
    targetWidth = Math.round(natural + PADDING_X);
    if (widthRaf) { return; }
    var current = btn.getBoundingClientRect().width;
    function tick() {
      current += (targetWidth - current) * WIDTH_LERP;
      btn.style.width = current + 'px';
      if (Math.abs(targetWidth - current) > 0.5) {
        widthRaf = requestAnimationFrame(tick);
      } else {
        btn.style.width = targetWidth + 'px';
        widthRaf = null;
      }
    }
    widthRaf = requestAnimationFrame(tick);
  }

05重複呼び出し防止 — 直前の要求時刻の値で次の呼び出しを止めるリスト

短時間にリストの終わりに何度も達した状態になると、直前の要求時刻の値との差を計算し、その値が基準より小さい間は新しい要求を出さず、値が基準を超えた状態になって初めて次の要求が送られます。

重複呼び出し防止 — 直前の要求時刻の値で次の呼び出しを止めるリスト
新しいタブで開く

05 · 重複呼び出し防止 — 直前の要求時刻の値で次の呼び出しを止めるリスト — 計測 累積面積 27.358% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

tryRequestCOOLDOWN_MSperformance.now

数字が示すもの

23フレーム中23フレームで動き、累積面積27.36%・平均強度46.1を記録しています。要求が止められた瞬間でもセンチネルが光り、見える回数の表示が増えるためです。tryRequestはperformance.now()の差をCOOLDOWN_MSと比べてから続行するかを決め、止められるたびに「止めた回数」の表示を黙って増やすので、短い間隔でリストの端を何度も踏んだ訪問者にも、自分のどれだけの操作が飲み込まれたかが見えます。

  // 計算層: 呼び出しの条件が整っても直前の要求時刻の値と現在時刻の値の差を計算し、
  // その値が待機基準より小さい間は要求を出さず止めた回数だけを数える。
  function tryRequest() {
    var now = performance.now();
    var since = now - lastRequestAt;
    if (since < COOLDOWN_MS) {
      blockedCount += 1;
      blockedEl.textContent = blockedCount + '回';
      sentinel.classList.add('dgd__sentinel--blocked');
      setTimeout(function () { sentinel.classList.remove('dgd__sentinel--blocked'); }, 180);
      return false;
    }
    lastRequestAt = now;
    loading = true;
    sentinel.classList.add('dgd__sentinel--loading');
    setTimeout(function () {
      for (var n = 0; n < 3; n += 1) { addRow(); }
      loading = false;
      batches += 1;
      sentinel.classList.remove('dgd__sentinel--loading');
    }, LOAD_DELAY_MS);
    return true;
  }

06終端到達の案内 — 残り項目の値が0になった瞬間に現れるメッセージ

次の一覧を呼び出した結果、残り項目の値が0になった状態になると、これ以上呼び出す一覧がないという計算が確定し、その状態では画面端に達しても値は求め直されません。

終端到達の案内 — 残り項目の値が0になった瞬間に現れるメッセージ
新しいタブで開く

06 · 終端到達の案内 — 残り項目の値が0になった瞬間に現れるメッセージ — 計測 累積面積 33.577% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

revealEndREVEAL_PXrequestAnimationFrame

数字が示すもの

23フレーム中21フレームで動き、累積面積33.58%・平均強度39.6を記録しています。大半は最終ページの行が追加される場面で、案内メッセージに切り替わる直前に集中しています。revealEndは単なるCSSトランジションの切り替えではなく、独自のrequestAnimationFrameループでREVEAL_PXから0へ指数的に値を近づけていき、そのループが終わった時点でセットされる完了フラグのおかげで、以後のどのスクロールイベントでもrecomputeは何も測らずすぐ戻ります。

  // 計算層: 案内文言の現れ方を固定トランジションではなくrAFで毎フレーム計算する——
  // 開始値をREVEAL_PXに置き、目標0まで指数減衰で近づける。残り項目の値が0になる前は
  // この計算自体が実行されない(一度確定すると再び計算されない)。
  function revealEnd() {
    endBox.hidden = false;
    var y = REVEAL_PX;
    function tick() {
      y += (0 - y) * 0.18;
      endBox.style.transform = 'translateY(' + y.toFixed(2) + 'px)';
      if (y > 0.4) { revealRaf = requestAnimationFrame(tick); }
      else { endBox.style.transform = 'translateY(0px)'; revealRaf = null; }
    }
    revealRaf = requestAnimationFrame(tick);
  }

07スクロール位置の保持 — 上に追加された高さの値の分だけ補正するリスト

画面上部に過去の項目を読み込んだ状態になると、新しく増えた高さの値を計算してスクロール位置の値にその分を加え、見ていた画面がずれて見えないよう位置がそのまま保たれます。

スクロール位置の保持 — 上に追加された高さの値の分だけ補正するリスト
新しいタブで開く

07 · スクロール位置の保持 — 上に追加された高さの値の分だけ補正するリスト — 計測 累積面積 23.703% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

loadOlderOLDER_BATCHscrollHeight

数字が示すもの

23フレーム中21フレームで動き、平均強度86.3は9つの中で最大です。補正された分だけ表示中のチャットログ全体が1フレームで一気にずれるため、じわじわ滑ったりフェードしたりする他のパターンより変化が濃く出ます。累積面積は23.70%です。loadOlderはOLDER_BATCH件のバブルをアンカーの上に挿入する前後でscrollHeightを測り、その2回の差をそのままscrollTopへ足し戻すため、読んでいた行が訪問者の目の前で動くことはありません。

  // 計算層: 上の一まとまりが届いたら増える直前の高さの値を保存しておき、増えたあとの
  // 高さの値との差(=上に追加された高さ)をscrollTopにそのまま足す——アンカー行が
  // 画面上で1ピクセルもずれない。
  function loadOlder() {
    if (loading || batches >= MAX_BATCHES) { return; }
    loading = true;
    var before = view.scrollHeight;
    anchor.classList.add('asp__anchor--loading');
    setTimeout(function () {
      var frag = document.createDocumentFragment();
      for (var n = 0; n < OLDER_BATCH; n += 1) {
        seq += 1;
        frag.appendChild(bubble(seq, seq % 3 === 0));
      }
      track.insertBefore(frag, anchor);
      var added = view.scrollHeight - before;
      view.scrollTop += added;   // 上に追加された高さの値の分だけ補正
      valueEl.textContent = '補正 ' + added + 'px';
      batches += 1;
      loading = false;
      anchor.classList.remove('asp__anchor--loading');
    }, LOAD_DELAY_MS);
  }

08仮想ウィンドウの再計算 — 見えている範囲のインデックス値だけを描くリスト

リストが増え続ける状態になると、スクロール位置の値から今見えているインデックス範囲の値を求め直し、その範囲外の項目は描かず、範囲が変わった状態になると上下の余白の値もその分求め直されます。

仮想ウィンドウの再計算 — 見えている範囲のインデックス値だけを描くリスト
新しいタブで開く

08 · 仮想ウィンドウの再計算 — 見えている範囲のインデックス値だけを描くリスト — 計測 累積面積 33.788% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

renderWindowOVERSCANscrollTop

数字が示すもの

23フレーム中23フレームで動き、累積面積33.79%・平均強度43.9を記録しています。毎回のスクロールで見える行の集合そのものが入れ替わる可能性があり、単に追加されるだけではないためです。renderWindowはscrollTopをROW_Hで割って開始インデックスを求め、OVERSCAN分だけ前後に余裕を持たせて速いスクロールでも一瞬空白が見えないようにし、その範囲から上下の余白の高さも求め直すため、実際にDOMへ存在する行は常時十数個程度に抑えられたまま全体の高さは正しく保たれます。

  // 計算層: scrollTopの値から今見えているインデックス範囲(start..end)を求め直し、
  // その範囲外の行はDOMに存在しない。上下の余白(spacer)の高さもその範囲の値から
  // 求め直して全体の高さを保つ。
  function renderWindow() {
    var scrollTop = view.scrollTop;
    var first = Math.floor(scrollTop / ROW_H) - OVERSCAN;
    var visible = Math.ceil(view.clientHeight / ROW_H) + OVERSCAN * 2;
    var start = Math.max(0, Math.min(first, available - visible));
    var end = Math.min(available, start + visible);
    if (rendered === start + ':' + end) { return; }
    rendered = start + ':' + end;
    while (win.firstChild) { win.removeChild(win.firstChild); }
    for (var i = start; i < end; i += 1) {
      var li = document.createElement('li');
      li.className = 'vwr__row';
      li.textContent = '行 ' + String(i + 1).padStart(3, '0');
      win.appendChild(li);
    }
    topSpacer.style.height = (start * ROW_H) + 'px';
    bottomSpacer.style.height = Math.max(0, (available - end) * ROW_H) + 'px';
    rangeEl.textContent = '行 ' + start + ' - ' + (end - 1) + ' / ' + available;
    domEl.textContent = '描画した行 ' + (end - start);
  }

09失敗リトライのカウントダウン — 待機時間の値が減る分だけ再試行するリスト

次の一覧の要求が失敗した状態になると、待機時間の値が決まった数から毎瞬間減っていき、その値が0になった状態になると同じ要求が自動的に再び送られます。

失敗リトライのカウントダウン — 待機時間の値が減る分だけ再試行するリスト
新しいタブで開く

09 · 失敗リトライのカウントダウン — 待機時間の値が減る分だけ再試行するリスト — 計測 累積面積 31.313% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

runCountdownCIRCperformance.now

数字が示すもの

23フレーム中23フレームで動き、累積面積31.31%・平均強度72.1を記録しています。カウントダウンリングの線と秒数の表示が待機中ずっと毎フレーム再描画され続けるためです。runCountdownは経過時間をWAIT_Sで割った値をrequestAnimationFrameのループでイーズアウト処理し、CIRC(円周)がその値をリングのstrokeDashoffsetに変えるので、リングは満杯から一気に消えるのではなく目に見えて減っていきます。

  // 計算層: 待機時間の値は静的なテキストではなくrAFで毎瞬間求め直される——経過時間の値を
  // easing(ここではease-out曲線)に入れて残り秒数とリングの長さ(円周×残り比率)を求める。
  function runCountdown(done) {
    counting = true;
    retryBox.hidden = false;
    var t0 = performance.now();
    function tick(now) {
      var elapsed = (now - t0) / 1000;
      var k = Math.min(elapsed / WAIT_S, 1);
      var eased = 1 - Math.pow(1 - k, 2);   // イーズアウト — 最初は速く減り、最後はゆっくり止まる
      var remainS = WAIT_S * (1 - eased);
      secondsEl.textContent = remainS.toFixed(1);
      arc.style.strokeDashoffset = String(CIRC * eased);
      if (k < 1) { countdownRaf = requestAnimationFrame(tick); }
      else { counting = false; retryBox.hidden = true; done(); }
    }
    countdownRaf = requestAnimationFrame(tick);
  }

数値がパターンを変える場所 — 距離の値・インデックス範囲の値・待機時間の値

同じマークアップのままでも、パターンの中心にある値の性質を変えるだけで体感はまったく別物になります。

03の基準線の距離の値が速度によって伸び縮みする理由

03の基準線は固定された40pxではなく、BASE_THRESHOLD_PXはあくまで下限です。スクロール速度が上がるとSPEED_GAINがその速度に掛けられて上乗せされ、MAX_THRESHOLD_PXで頭打ちになるため、どれだけ速く指を滑らせてもリストの途中から読み込みが始まることはありません。基準線を固定値ではなく速度で伸び縮みする値として扱うからこそ、速い訪問者の目の前に空白が出ません。

08の見えている範囲のインデックス値がひとつの割合では済まない理由

08は01のような「どこまでスクロールしたか」の単一の割合では動けません。仮想化されたリストは終わりまでの近さではなく、DOMに残すべき正確な行の範囲を知る必要があるからです。renderWindowはscrollTop・ROW_H・OVERSCANからstartとendを同時に求め直し、この対をひとまとまりの値として扱うからこそ、余白の高さと描画された行が互いにずれずに保たれます。

09の待機時間の値がカウントダウンで減っていく理由

09のWAIT_Sは単一のsetTimeoutで「待機中」から「準備完了」へ何もないまま切り替えることもできたはずですが、経過時間の値を毎フレーム求め直してリングと秒数を減らしていくことで、失敗した要求を見ている訪問者は、ページが止まったのではなくまもなく再試行することが見て分かります。

どこで壊れるか — 高速スクロール・低速回線・空のリスト

この9つと【コピペOK】CSS スケルトン ローディング 9選・【コピペOK】プログレスバー css アニメーション 9選・スワイプ削除リストUI 9選 — ページネーションまでとの違いは、それぞれが求め直す値そのものです。スケルトンの回はプレースホルダー行ごとのシマーのずれの値を求め直し、スクロール距離とは無関係です。プログレスバーの回はページ全体の高さに対する単一の割合の値だけを求め直します。スワイプ削除の回はドラッグ距離の値と離した位置の基準を比べるだけで、要求とは結びついていません。この9つはそれとはまったく違う値——距離からの不透明度、ビューポートからの行数、速度からの基準線、ラベルからの幅、時刻からの待機、完了の数、挿入高さからのスクロール補正、scrollTopからのインデックス範囲、経過時間からのカウントダウン——を求め直しています。qp334h28を開くと、この9つすべての計算部分がそのままコピーして使える形で入っています。

高速スクロールが重複防止と読み込み開始の基準線に与える影響

短い間隔で何度もセンチネルに触れる訪問者は、1回の要求が終わる前に何度も端へ達することがあります。05のCOOLDOWN_MSはまさにこれが重なった要求にならないよう止め、止めた回数を「止めた呼び出し」の表示に黙って足していきます。03はまったく逆の発想で同じ問題に対処します。速い訪問者の追加操作を止めるのではなく、基準線の値そのものを広げて、繰り返しの操作が起きる前に早く次の一覧を読み込み始めます。

低速回線が再試行のカウントダウンとボタンの変形に与える影響

09は本物の回線が時々失敗したり詰まったりするからこそ存在し、そのカウントダウンは訪問者に、リストが固まったのではなく待つ理由があることを見せます。この9つの中で、演技であるsetTimeoutの遅延がわざと失敗を表すために使われているのは09だけです。04のボタン変形にも似た問題が静かに現れます。もっと見るの要求が遅ければ、応答が届くまでボタンの待機ラベルと幅はそのまま保たれ、説明もなく元に戻ることはありません。

空になった、または尽きたリストが終端メッセージと仮想ウィンドウに与える影響

06はリストが尽きる瞬間のために作られています。pageがTOTAL_PAGESに達すると、この9つの他のどれとも違い、以後どのスクロールイベントも計算を再開させません。08は同じ端を別の形で扱います。このデモではavailableが有限の既知の数なので、renderWindowのMath.minがendをその値より超えさせず、最後の行を過ぎてスクロールしても範囲がそこで止まり、存在しない項目を描こうとはしません。

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

以下の数値はプレースホルダーの行・時刻・メッセージを実データに差し替えたあとの出発点として扱ってください。求め直す値の性質は同じで、定数だけが変わります。

バリエーション 変わるもの トレードオフ
下部スピナーの基準線 01のTHRESHOLD_PX定数を96pxから220pxへ 96pxではリストの終わりにかなり近づいてからでないとスピナーが薄く見え始めず気づきにくく、220pxではずっと早い時点から徐々に濃くなり予告は長くなるが、その分スピナーが画面に出ている時間も伸びる
もっと見るボタンの補間係数 04のWIDTH_LERP定数を0.24から0.65へ 0.24では目標幅まで複数フレームかけて滑らかに追いつき重みを感じさせるが、0.65では1〜2フレームで目標幅に届き即座だが弾力のない止まり方になる
再試行の待機時間 09のWAIT_S定数を3から8へ 3秒では失敗から再試行までがすぐ続きリングが一瞬で流れて見えにくいが、8秒ではリングがゆっくり回る区間が長くなり再試行を待っていることは明確になる一方、訪問者はそれだけ長く止まったリストを見続けることになる

このzipは96px・0.24・3秒の値のまま入っています。

操作を減らす設定や読み上げの利用者にも同じ計算値が届く理由

9つともprefers-reduced-motionを確認し、それが有効な間はデモ自身の自動再生プレビューのループだけを止めます。本物のscroll・wheel・touchstart・clickのリスナーはそのまま残るため、操作を減らす設定を使う訪問者がリストをスクロールしたりもっと見るを押したりすれば、演出なしで同じ不透明度・基準線・幅・カウントダウンの値がそのまま届きます。ライブリージョンは値の変化を知らせる価値がある場所だけに絞って置かれています。01の行一覧はaria-live="polite"で新しい行が届くたびに一度だけ読み上げられ、02は実データが届くまでリストのaria-busyを切り替え、06の完了メッセージと09の状態表示はどちらもrole="status"です。03・05・07・08のセンチネルや余白の要素はaria-hiddenで隠されています。これらは位置を測るためだけに存在し、それ自体には読み上げる中身がないためです。

FAQ

無限スクロール やり方として、無限に読み込むのとボタンで読み込むのはどちらを選ぶべきですか

その後に訪問者が何をしたいかで決まります。フィードを下へ流し見るだけなら無限スクロール(この9つのうち01・02・03・05・06・07・08・09)が向いていて、数えたり途中の位置に戻ったりしたいリストなら04のようなもっと見るボタンが向いています。クリックした位置がそのまま保たれるためです。

この9つは無限スクロール 開発者にとってただの例なのか、そのまま使えるコードなのか

例として眺めるだけでなく、そのまま使うことを前提に作られています。上に載せた計算部分はどれも25行以内で外部ライブラリを使わず、差し替えが必要なのはデータを取ってくる層だけです。

この9つのうち本物のサーバーと通信しているものはありますか、それとも無限スクロール サイトの動きをローカルで演じているだけですか

どれも通信していません。行もメッセージも再試行も種を固定したローカルの一覧から生成され、setTimeoutが遅延の役を演じているだけです。データの取得部分を本物のfetch呼び出しに差し替えることだけが、この9つを実運用へ進める唯一の変更点です。

Sources: MDN — Element.scrollHeight · MDN — Window.requestAnimationFrame · MDN — ARIA status role · MDN — prefers-reduced-motion

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

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