無限スクロール
コンテナ内を下までスクロールすると自動でアイテムを追加読み込み。IntersectionObserverでsentinelを監視。
プレビュー
操作枠内をスクロールすると動きます。
仕様書
- 向く場面
- SNSタイムライン・通知など終わりを決めにくいフィード
- 件数が多く流し読みが主な一覧
- 固定高さのコンテナ内で続きを読ませたい
- 避ける場面
- フッターや関連リンクに到達させたいページ
- 件数・順位を把握させたい検索結果
- 戻る操作でスクロール位置復元が重要な画面
- 構成要素
- 一覧
- 項目
- 読み込み中スピナー
- 終端メッセージ
- 監視用の番兵
- 持っている状態
- 通常
- 読み込み中
- キーボード
Tab で一覧領域へ(tabindex=0)・↑↓/PageDown/End でスクロールすると番兵に達して追加読み込みが走る
- 支援技術
一覧に tabindex=0 と aria-label、追加件数と終端を sr-only の role=status で告知。読み込み中はスピナーに「読み込み中…」の文言を併記
- 動き
スピナー回転 0.7s linear infinite。項目の追加は即時。reduced-motion では回転を止め文言だけにする
デザインの要点
- 監視は末尾の番兵 1 要素だけ。scroll イベントで scrollTop を毎回計算するより軽く、rootMargin 20px で見える手前から先読みできる
- 追加・スピナー・終端メッセージは全部 insertBefore(番兵) で入れ、番兵を常に最後尾に保つ
- loading フラグで重複発火を止める。交差イベントは短時間に何度も飛ぶので、フラグが無いと同じバッチを二重に足す
- プールを使い切ったら unobserve する。終端メッセージの後も監視が生きていると、スクロールのたびに空の読み込みが走る
使いどころ
総件数が多い、または明確な終わりを設計時に決めにくいフィード(SNSタイムライン、通知一覧、記事一覧)に向く。ページ送りのクリックを挟まずに読み進められる没入感が利点で、このサンプルのようにIntersectionObserverでスクロール終端の少し手前から次のバッチを先読みすれば、ユーザーが本当の最下端に着く前に読み込みが完了し、途切れを感じさせない。向かないのは、フッターや関連リンクなど一覧の下にある固定コンテンツに到達してほしい画面、特定の位置への共有リンクやブラウザの戻るボタンでのスクロール位置復元が重要な画面、検索結果のように件数や順位を利用者が把握しておきたい画面である。後者はページ番号付きの一覧かページボタンの方が適し、無限スクロールは何件中何件目にいるかの感覚を利用者から奪いやすい。
仕組み
IntersectionObserverのrootにビューポートではなく.is-list(固定高さの内側スクロールコンテナ)自身を指定している点が要で、これによりページ全体ではなくカード内の小さな領域のスクロールでも監視が成立する。監視対象のsentinelは常にリストの最後尾に置かれ、新しい項目やスピナー、終端メッセージは全てinsertBefore(要素, sentinel)の形でsentinelの直前に挿入することで、sentinel自身の位置を最後尾に保ち続ける。rootMarginの下端20pxはsentinelが実際に見える20px手前で交差を検知させる先読みの仕掛けで、擬似的な800msの読み込み待ちがユーザーの体感の前に終わるよう調整されている。loadingという真偽値フラグで、読み込み中の重複発火(短時間に何度も交差イベントが飛ぶ)を防いでいる。プールを使い切ったらio.unobserve(sentinel)で監視自体を止め、以降の無駄な交差検知を防ぐ。
実装の注意
最も踏みやすいのは、ページ全体をスクロールさせる設計に転用するときにrootをlistのままにしてしまい、監視が発火しなくなる事故。ページ単位にするならrootをnull(ビューポート基準)に変え、sentinelもページの本当の末尾に置き直す必要がある。次に、コンテナの高さに対して初期コンテンツが少ないと、挿入直後にsentinelがすぐ画面内に入ってしまい、loadingフラグでガードしていても短時間に連続してaddItemsが呼ばれ、想定より速いペースで全件を読み込み切ってしまう暴走読み込みが起きやすい。初期表示だけでビューポートを満たせる件数を確保しておくのが対策になる。またrootMarginが小さすぎると、ユーザーが最下端に着いてからスピナーが表示され引っかかっているように感じ、大きすぎると本当に見る前のバッチまで先読みして帯域を無駄にする。適切な値はコンテンツの行の高さと想定スクロール速度から逆算して決めるべきである。
アクセシビリティ
無限スクロールの代表的な問題が「フッター到達不能問題」で、下に続くはずのサイトフッターや関連ナビが、読み込みが終わらない限り永遠に手前に来てしまい、キーボード・スクリーンリーダー利用者だけでなく晴眼のマウス利用者も到達できなくなる。このサンプルは.is-listという固定高さの内側コンテナだけをスクロールさせる構造で、ページのフッターはそもそもこのコンテナの外にあるため、この問題を実質的に回避できている。ただしページ全体スクロールに転用するときは同じ問題が再発するため、一定件数を自動読み込みしたら「もっと見る」ボタンへ切り替える対策が要る。加えて、新規追加された件数と終端に達した旨は画面外のrole="status"(aria-live="polite")領域に書き込んでスクリーンリーダーへ知らせている。視覚的な「読み込み中…」の文字列は見えるユーザー向けで、読み上げには「3件を追加しました」のような結果だけを伝える(読み込み中まで逐一読ませると、追加のたびに2回の割り込みになる)。.is-list自体にはtabindex="0"とaria-labelを付け、キーボード操作でこの内側コンテナを直接スクロールして番兵に到達できるようにしてある(同じ辞典の仮想スクロールの.vs-viewportと同じ作法)。
調整して使う
バッチサイズ(このサンプルは3件ずつ)は行の高さと読み込みコストで決める。細かい行なら5〜10件、重いカードなら1〜2件が扱いやすい。読み込み中の表現もスピナーに限らず、スケルトン行を数個差し込む形にすると、読み込み後の項目の形があらかじめ想像でき、切り替わりの唐突さが減る。rootMargin(先読み量)は接続速度が読めないモバイル中心の想定ならやや大きめに、PC中心で高速な想定なら小さめにする。チャットのように過去の履歴を上方向へ遡って読み込む逆方向の無限スクロールもあり、その場合はsentinelを先頭に置き、追加した項目ぶんscrollTopを補正してスクロール位置が飛ばないようにする実装が要る。件数に上限を設け、一定量を超えたら「もっと見る」ボタンに切り替えるハイブリッド型は、フッター到達不能問題への現実的な妥協案として使われる。
コード(コピーして使えます)
<div class="is-root">
<div class="is-header">記事一覧</div>
<p class="is-status" id="is-status" role="status" aria-live="polite"></p>
<div class="is-list" id="is-list" tabindex="0" aria-label="記事一覧(スクロールで続きを読み込み)">
<div class="is-item"><span class="is-badge">新着</span>UI設計の基本原則</div>
<div class="is-item">アニメーションで伝わるデザイン</div>
<div class="is-item">タイポグラフィの選び方</div>
<div class="is-item">カラーパレットの作り方</div>
<div class="is-item">グリッドレイアウト入門</div>
<div class="is-sentinel" id="is-sentinel"></div>
</div>
</div>