List 5 — リスト 5 — 無限スクロール

データ表示 中級

このコンポーネントについて

スクロールが一覧の末尾に近づくと、次のデータを自動で読み込んでカードを追加表示します。 「次へ」ボタンを押す必要がないため、SNSのタイムラインやニュースフィードのようにコンテンツを途切れなく閲覧できます。

データはJSONファイルから fetch() で取得し、5件ずつバッチで描画します。 全件表示後は「すべて表示しました」のメッセージを出して終了します。 ページング(リスト 4 — ページング)の代替パターンとして比較しながら学べます。

  • IntersectionObserver — スクロール位置をポーリングせず、センチネル要素が画面内に入ったタイミングで次バッチをロードする
  • 5件×4バッチ(計20件) — JSONの全20件を5件ずつ分割して表示。実務ではAPIのエンドポイントに差し替えるだけで動く構造にしている
  • ローディングスピナー — バッチ取得中はスピナーを表示し、完了後に非表示にする
  • 終端メッセージ — 全件表示後に「すべて表示しました」を出してObserverを停止する
  • リセット — リセットボタンで初期状態(5件表示)に戻る

実装のポイント・注意点

スクロールイベントではなく IntersectionObserver を使うことが現代的なアプローチです。 scroll イベントは毎フレーム発火してパフォーマンスに影響しますが、IntersectionObserver は要素が画面内に入った瞬間だけコールバックを呼ぶため、負荷が最小限に抑えられます。

センチネル要素(sentinel)とは、リスト末尾に置く height: 1px の不可視 div です。 この要素をObserverで監視し、画面内に入ったら次バッチをロードします。カードが増えると要素は画面下に押し下げられ、再度スクロールするまでObserverは発火しません。

isLoading フラグで連続発火を防ぐことが重要です。 スピナーが表示されている間にセンチネルが画面内に残っていると、Observerが連続して発火することがあります。 isLoadingtrue の間はObserverのコールバックを無視することで二重ロードを防いでいます。

リセット時は必ず observer.disconnect() を呼ぶことで古いObserverを停止します。 停止せずに initObserver() を再呼び出しすると、古いObserverと新しいObserverが両方動いてしまい、1回のスクロールで2バッチ分ロードされる原因になります。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

下にスクロールしてカードの末尾が見えると、次の5件が自動で追加されます。全20件。

サンプルソース

4つのファイルを同じフォルダに保存し、簡易サーバーで index.html を開くと動作確認できます。
ファイル構成:index.html / style.css / script.js / data/data.jsondata フォルダを作って中に配置)
保存時の文字コードは UTF-8 を指定してください。fetch()file:// では動作しないため、VS Code の Live Server 等で開いてください。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>無限スクロール サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="is-wrap">
  <!-- カードグリッド(カードはJSで動的追加) -->
  <div class="is-grid" id="js-grid"></div>

  <!-- ローディングスピナー -->
  <div class="is-loader" id="js-loader" hidden>
    <span class="is-spinner"></span>
  </div>

  <!-- 終端メッセージ(全件表示後に表示) -->
  <p class="is-end" id="js-end" hidden>すべて表示しました</p>

  <!-- センチネル:このdivが画面内に入ったら次バッチをロード -->
  <div class="is-sentinel" id="js-sentinel"></div>
</div>

<script src="./script.js"></script>
</body>
</html>
/* 無限スクロール — style.css */
*, *::before, *::after { box-sizing: border-box; }

:root {
  --color-primary: #2B7FE8;
  --color-border:  #E2E8F0;
  --color-text:    #1A2332;
  --color-muted:   #64748B;
}

body {
  font-family: sans-serif;
  padding: 24px;
  max-width: 900px;
  margin: 0 auto;
  background: #F8FAFC;
  color: var(--color-text);
}

/* ---- カードグリッド ---- */
.is-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
  margin-bottom: 4px;
}

/* ---- カード ---- */
.is-card {
  background: #fff;
  border: 1px solid var(--color-border);
  border-radius: 10px;
  padding: 18px 20px;
  transition: box-shadow 0.15s;
}

.is-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}

/* ---- タグバッジ ---- */
.is-tag {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 9999px;
  font-size: 11px;
  font-weight: 700;
  margin-bottom: 10px;
}

.is-tag--css        { background: #E0F2FE; color: #0369A1; }
.is-tag--javascript { background: #FEF3C7; color: #B45309; }
.is-tag--html       { background: #FEE2E2; color: #B91C1C; }

/* ---- カードテキスト ---- */
.is-title {
  font-size: 15px;
  font-weight: 700;
  color: var(--color-text);
  margin: 0 0 8px;
  line-height: 1.4;
}

.is-body {
  font-size: 13px;
  color: var(--color-muted);
  line-height: 1.6;
  margin: 0;
}

/* ---- ローダー(スピナー) ---- */
.is-loader {
  text-align: center;
  padding: 20px;
}

.is-spinner {
  display: inline-block;
  width: 28px;
  height: 28px;
  border: 3px solid var(--color-border);
  border-top-color: var(--color-primary);
  border-radius: 50%;
  animation: is-spin 0.7s linear infinite;
}

@keyframes is-spin {
  to { transform: rotate(360deg); }
}

/* ---- 終端メッセージ ---- */
.is-end {
  text-align: center;
  font-size: 13px;
  color: #94A3B8;
  padding: 20px;
  margin: 0;
}

/* ---- センチネル(不可視のスクロール検知用要素) ---- */
.is-sentinel {
  height: 1px;
}
// ============================================================
// 無限スクロール — script.js
// ============================================================

// --- 状態管理変数 ---
var allItems = [];     // 読み込んだ全件データ
var currentBatch = 0; // 次に読み込むバッチ番号(0始まり)
var BATCH_SIZE = 5;   // 1バッチの表示件数
var observer = null;  // IntersectionObserver インスタンス
var isLoading = false; // 連続発火を防ぐフラグ

// --- DOM要素を取得 ---
var gridEl     = document.getElementById('js-grid');
var loaderEl   = document.getElementById('js-loader');
var endEl      = document.getElementById('js-end');
var sentinelEl = document.getElementById('js-sentinel');

// --- JSONを読み込んで初期化(最初の1回だけ通信する) ---
// 実際のAPIに差し替える場合はここのURLを変更する
fetch('./data/data.json')
  .then(function(res) {
    if (!res.ok) { throw new Error('HTTP ' + res.status); }
    return res.json();
  })
  .then(function(data) {
    allItems = data.items; // 全件をメモリに保持
    renderBatch();         // バッチ1(最初の5件)を描画
    initObserver();        // IntersectionObserverを開始
  })
  .catch(function(err) {
    console.error('読み込みエラー:', err);
    var p = document.createElement('p');
    p.style.cssText = 'color:#9B1C1C; padding:16px; font-size:14px;';
    p.textContent = 'データを読み込めませんでした。ローカルサーバーで開いているか確認してください。';
    gridEl.appendChild(p);
  });

// --- IntersectionObserverの初期化 ---
function initObserver() {
  observer = new IntersectionObserver(function(entries) {
    // センチネルが画面内に入り、かつローディング中でなければ次バッチを描画
    if (entries[0].isIntersecting && !isLoading) {
      renderBatch();
    }
  }, { threshold: 0 });

  observer.observe(sentinelEl);
}

// --- 次の5件をカードとして追加描画 ---
function renderBatch() {
  var start = currentBatch * BATCH_SIZE;

  // 全件表示済みなら何もしない
  if (start >= allItems.length) { return; }

  isLoading = true;
  loaderEl.hidden = false; // スピナーを表示

  // 400ms 後に描画(実際のAPIに差し替える場合はここを fetch 呼び出しに変える)
  setTimeout(function() {
    var batch = allItems.slice(start, start + BATCH_SIZE);
    batch.forEach(function(item) {
      gridEl.appendChild(createCard(item));
    });

    currentBatch++;
    loaderEl.hidden = true; // スピナーを非表示
    isLoading = false;

    // 全件描画済みならObserverを止めて終端メッセージを表示する
    if (currentBatch * BATCH_SIZE >= allItems.length) {
      if (observer) { observer.disconnect(); }
      endEl.hidden = false;
    }
  }, 400);
}

// --- カードのDOM要素を生成 ---
function createCard(item) {
  var card = document.createElement('div');
  card.className = 'is-card';

  // タグバッジ(CSS/JavaScript/HTMLで色を変える)
  var tag = document.createElement('span');
  tag.className = 'is-tag is-tag--' + item.tag.toLowerCase();
  tag.textContent = item.tag;

  // タイトル
  var title = document.createElement('p');
  title.className = 'is-title';
  title.textContent = item.title;

  // 本文
  var body = document.createElement('p');
  body.className = 'is-body';
  body.textContent = item.body;

  card.appendChild(tag);
  card.appendChild(title);
  card.appendChild(body);
  return card;
}

// --- リセット ---
function resetDemo() {
  // Observer停止・フラグリセット
  if (observer) { observer.disconnect(); }
  currentBatch = 0;
  isLoading = false;

  // 描画クリア・UI初期化
  gridEl.textContent = '';
  endEl.hidden = true;
  loaderEl.hidden = true;

  // バッチ1を再描画してObserverを再開
  renderBatch();
  initObserver();
}
{
  "items": [
    { "id": 1,  "title": "Flexboxで縦横中央寄せ",             "body": "display: flex と align-items / justify-content を組み合わせる最もシンプルな中央寄せの方法です。",           "tag": "CSS" },
    { "id": 2,  "title": "CSSグリッドで2カラムレイアウト",     "body": "grid-template-columns: repeat(2, 1fr) でレスポンシブな2カラムを手軽に作れます。",                      "tag": "CSS" },
    { "id": 3,  "title": "fetch()で外部JSONを取得する",        "body": "fetch() と .then() を使ったシンプルな非同期通信の基本パターンです。",                                    "tag": "JavaScript" },
    { "id": 4,  "title": "IntersectionObserverの基本",        "body": "要素が画面内に入ったことをコールバックで検知する最新のブラウザAPIです。",                                "tag": "JavaScript" },
    { "id": 5,  "title": "LocalStorageにデータを保存する",     "body": "ページをまたいで状態を保持したいときに使うブラウザのストレージAPIです。",                                "tag": "JavaScript" },
    { "id": 6,  "title": "CSS変数でテーマカラーを管理",        "body": ":root に --color-primary などの変数を定義してサイト全体で再利用する方法です。",                          "tag": "CSS" },
    { "id": 7,  "title": "位置固定ヘッダーの作り方",           "body": "position: sticky と top: 0 を使うと、スクロールしても画面上部に追従するヘッダーを作れます。",            "tag": "CSS" },
    { "id": 8,  "title": "イベント委譲(Event Delegation)",   "body": "子要素が動的に増えるとき、親要素にイベントを1つ登録するだけで済む効率的な手法です。",                    "tag": "JavaScript" },
    { "id": 9,  "title": "フォームのバリデーションをJSで実装", "body": "送信前に必須チェック・形式チェックを行い、エラーメッセージをリアルタイム表示する基本パターンです。",      "tag": "JavaScript" },
    { "id": 10, "title": "擬似要素でデザインアクセントを追加", "body": "::before / ::after を使うことでHTMLを変えずに装飾を追加できます。",                                      "tag": "CSS" },
    { "id": 11, "title": "レスポンシブ画像の書き方",           "body": "img の srcset と sizes 属性で画面サイズに合った解像度の画像を自動選択させる方法です。",                   "tag": "HTML" },
    { "id": 12, "title": "クリップボードにテキストをコピー",   "body": "navigator.clipboard.writeText() を使った現代的なコピー実装です。",                                     "tag": "JavaScript" },
    { "id": 13, "title": "CSSアニメーションの基本",            "body": "@keyframes と animation プロパティで要素をなめらかに動かす基本的な書き方です。",                         "tag": "CSS" },
    { "id": 14, "title": "スクロール位置によるクラス切り替え", "body": "window.scrollY を監視し、一定量スクロールしたらヘッダーのクラスを切り替えるパターンです。",               "tag": "JavaScript" },
    { "id": 15, "title": "テーブルのソート(昇順・降順)",     "body": "カラムヘッダーのクリックで配列を sort() してテーブルを再描画するバニラJS実装です。",                     "tag": "JavaScript" },
    { "id": 16, "title": "ダークモードの切り替え",             "body": "data-theme 属性とCSS変数を組み合わせてライト / ダークを切り替える実装です。",                            "tag": "CSS" },
    { "id": 17, "title": "モーダルの開閉をCSSだけで実装",      "body": ":target 擬似クラスを使うことでJavaScriptなしでモーダルの開閉が実現できます。",                          "tag": "CSS" },
    { "id": 18, "title": "ドラッグ&ドロップでファイル受け取り", "body": "dragover / drop イベントと FileReader API を組み合わせたファイル受け取りの基本実装です。",            "tag": "JavaScript" },
    { "id": 19, "title": "URLパラメーターの取得と操作",        "body": "URLSearchParams を使うと ?key=value 形式のクエリ文字列を簡単に読み書きできます。",                      "tag": "JavaScript" },
    { "id": 20, "title": "アクセシビリティの基本:aria属性",   "body": "aria-label / aria-hidden / role を適切に設定することでスクリーンリーダー対応の第一歩になります。",       "tag": "HTML" }
  ]
}

AI用プロンプト

このプロンプトをChatGPTやClaudeに渡すと、同様の無限スクロールコンポーネントをゼロから生成・カスタマイズできます。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 カードのフィールド数やバッチ件数は要件に合わせて変更してください。jQuery・Vue・Reactで実装したい場合はプロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# 無限スクロール(カードリスト)作成依頼

## 概要
JSONファイルからデータを取得し、スクロールで5件ずつカードを追加表示する無限スクロールUIを実装してください。

## 要件
- fetch() でJSONファイル(items配列: id/title/body/tag)を取得する
- 取得した全データはメモリに保持し、5件ずつバッチで表示する
- IntersectionObserverでリスト末尾のセンチネル要素を監視し、画面内に入ったら次の5件を追加描画する
- バッチ取得中はローディングスピナーを表示する
- 全件表示後はObserverを停止し「すべて表示しました」のメッセージを出す
- リセットボタンで初期状態(最初の5件表示)に戻れるようにする
- カードは tag / title / body の3フィールドを表示する(tagはCSS/JavaScript/HTMLで色分け)

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要

## 動作詳細
fetch().then() でJSONを取得して allItems に保存し、初回はバッチ0(0〜4件目)を描画する。
IntersectionObserverはリスト末尾の不可視センチネルdivを監視し、isIntersecting が true になったら renderBatch() を呼ぶ。
isLoading フラグで連続発火を防ぐ(ローディング中はObserverのコールバックを無視する)。
renderBatch() は currentBatch * BATCH_SIZE を起点に slice() で5件を切り出してカードを追加する。
全件描画後は observer.disconnect() でObserverを止め、終端メッセージを表示する。
リセット時は observer.disconnect() で旧Observerを停止してから再初期化する。
すべてのDOM操作は createElement / textContent を使い、innerHTML に変数を渡さない。
実際のAPIに差し替える場合は renderBatch() 内の setTimeout をそのまま fetch 呼び出しに変えるだけでよい。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。