無限スクロール

IntersectionObserverで末尾到達を検知し追加読み込みするフィード。スピナーと上限制御付きで、タイムラインや一覧表示に使えます。

#javascript#intersection-observer#css

ライブデモ

使用例(お題: アイドルグループ Sakura)

この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- Sakura:公式ニュースを無限スクロールで読み込み -->
<div class="idol">
  <header class="idol__bar">
    <span class="idol__logo">🌸 Sakura</span>
    <span class="idol__count" id="count">0件</span>
  </header>
  <p class="idol__h1">NEWS / お知らせ</p>

  <ul class="feed" id="list">
    <li class="sentinel" id="sentinel">
      <span class="spinner" aria-hidden="true"></span>
      <span>読み込み中…</span>
    </li>
  </ul>
</div>
CSS
/* Sakura アイドル テーマ */
:root{--pink:#ffd1e0;--deep:#e86a96;--ink:#4a3540;--line:#f0dde4;--muted:#9b8690}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;font-family:"Hiragino Kaku Gothic ProN","Segoe UI",sans-serif;background:#fff5f9;color:var(--ink)}
.idol{max-width:440px;margin:0 auto;height:100vh;display:flex;flex-direction:column;padding:0 16px}
.idol__bar{display:flex;align-items:center;justify-content:space-between;padding:14px 4px 6px}
.idol__logo{font-weight:800;color:var(--deep)}
.idol__count{font-size:.74rem;color:var(--muted)}
.idol__h1{margin:0 0 10px;font-size:.78rem;letter-spacing:.12em;color:var(--deep);font-weight:700}
/* スクロール領域(この中で無限読み込み) */
.feed{list-style:none;margin:0;padding:0 0 10px;overflow-y:auto;flex:1}
.feed::-webkit-scrollbar{width:6px}
.feed::-webkit-scrollbar-thumb{background:var(--pink);border-radius:6px}
.news{display:flex;gap:12px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:12px;margin-bottom:10px;box-shadow:0 3px 10px rgba(232,106,150,.06)}
.news__thumb{width:64px;height:64px;border-radius:10px;background-size:cover;background-position:center;flex:none}
.news__body{display:flex;flex-direction:column;gap:4px;min-width:0}
.news__tag{align-self:flex-start;font-size:.64rem;font-weight:800;letter-spacing:.06em;color:var(--deep);background:var(--pink);padding:2px 8px;border-radius:999px}
.news__title{font-weight:700;font-size:.9rem;line-height:1.4}
.news__date{font-size:.72rem;color:var(--muted)}
.sentinel{display:flex;align-items:center;justify-content:center;gap:8px;padding:14px;color:var(--muted);font-size:.8rem}
.spinner{width:16px;height:16px;border:2px solid var(--pink);border-top-color:var(--deep);border-radius:50%;animation:spin .7s linear infinite}
.sentinel.is-done .spinner{display:none}
@keyframes spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.spinner{animation:none}}
JavaScript
// IntersectionObserver で末尾検知 → 公式ニュースを追加読み込み
const list = document.getElementById('list');
const sentinel = document.getElementById('sentinel');
const countEl = document.getElementById('count');

if (list && sentinel) {
  // ニュース見出しの素材(組み合わせてダミー記事を生成)
  const TAGS = ['LIVE', 'MEDIA', 'GOODS', 'MV', 'RADIO'];
  const TITLES = [
    '春のワンマンライブ「桜花繚乱」追加公演が決定',
    '新曲「春風センセーション」MVを公開しました',
    'メンバーが表紙の雑誌インタビュー掲載',
    '公式オンラインストアに新グッズが登場',
    '冠ラジオ番組のゲスト出演が決定しました',
    'ファンクラブ限定の特典映像を更新しました',
  ];
  const PER_PAGE = 5;
  const MAX = 30; // 上限に達したら停止
  let loaded = 0;
  let busy = false;

  const daysAgo = (i) => {
    const d = new Date(2026, 2, 28 - i); // 架空の日付を生成
    return `2026.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
  };

  const addBatch = () => {
    if (busy || loaded >= MAX) return;
    busy = true;
    // 読み込み遅延を擬似的に再現
    setTimeout(() => {
      const frag = document.createDocumentFragment();
      for (let i = 0; i < PER_PAGE && loaded < MAX; i++, loaded++) {
        const li = document.createElement('li');
        li.className = 'news';
        const thumb = document.createElement('span');
        thumb.className = 'news__thumb';
        thumb.style.backgroundImage = `url('https://picsum.photos/120/120?random=${40 + loaded}')`;
        const body = document.createElement('span');
        body.className = 'news__body';
        body.innerHTML =
          `<span class="news__tag">${TAGS[loaded % TAGS.length]}</span>` +
          `<span class="news__title"></span>` +
          `<span class="news__date">${daysAgo(loaded)}</span>`;
        body.querySelector('.news__title').textContent = TITLES[loaded % TITLES.length];
        li.appendChild(thumb);
        li.appendChild(body);
        frag.appendChild(li);
      }
      // センチネルを末尾に保つため、その手前へ挿入
      list.insertBefore(frag, sentinel);
      if (countEl) countEl.textContent = `${loaded}件`;

      if (loaded >= MAX) {
        sentinel.classList.add('is-done');
        const label = sentinel.querySelector('span:last-child');
        if (label) label.textContent = 'すべて読み込みました';
        io.disconnect();
      }
      busy = false;
    }, 600);
  };

  // 末尾センチネルが見えたら次を読む
  const io = new IntersectionObserver((entries) => {
    entries.forEach((e) => { if (e.isIntersecting) addBatch(); });
  }, { root: list, threshold: 0.1 });
  io.observe(sentinel);

  addBatch(); // 初回ロード
}

コード

HTML
<!-- 無限スクロール:IntersectionObserver で末尾到達を検知し追加読み込み -->
<div class="feed">
  <header class="feed__head">
    <h2 class="feed__title">アクティビティ</h2>
    <span class="feed__count" id="count">0件</span>
  </header>

  <ul class="feed__list" id="list">
    <!-- 監視対象(スクロール要素の内側)。ここが見えたら次を読み込む -->
    <li class="feed__sentinel" id="sentinel">
      <span class="spinner" aria-hidden="true"></span>
      <span>読み込み中…</span>
    </li>
  </ul>
</div>
CSS
:root{
  --bg:#0e1320;
  --card:#181f30;
  --accent:#38bdf8;
  --text:#e4e9f3;
  --muted:#8a95ad;
}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;
  display:grid;place-items:center;padding:18px;
  font-family:"Segoe UI",system-ui,sans-serif;color:var(--text);
  background:radial-gradient(700px 360px at 50% -10%,#1a2740,transparent),var(--bg);
}
.feed{
  width:min(420px,100%);height:340px;
  display:flex;flex-direction:column;
  background:var(--card);border:1px solid #232c42;border-radius:18px;
  overflow:hidden;box-shadow:0 26px 50px -26px rgba(0,0,0,.7);
}
.feed__head{
  display:flex;align-items:center;justify-content:space-between;
  padding:16px 18px;border-bottom:1px solid #232c42;
  background:linear-gradient(180deg,rgba(56,189,248,.08),transparent);
}
.feed__title{margin:0;font-size:1.05rem}
.feed__count{color:var(--muted);font-size:.8rem;font-variant-numeric:tabular-nums}
/* スクロール領域 */
.feed__list{
  flex:1;overflow-y:auto;margin:0;padding:8px;list-style:none;
  scrollbar-width:thin;scrollbar-color:#384663 transparent;
}
.feed__list::-webkit-scrollbar{width:8px}
.feed__list::-webkit-scrollbar-thumb{background:#384663;border-radius:8px}
.card{
  display:flex;align-items:center;gap:12px;
  padding:11px 12px;margin:6px 4px;border-radius:12px;
  background:#1f2840;
  animation:rise .35s ease both;
}
.card__avatar{
  flex:none;width:38px;height:38px;border-radius:50%;
  display:grid;place-items:center;font-weight:700;color:#0e1320;
}
.card__body{min-width:0}
.card__name{font-weight:600;font-size:.9rem}
.card__meta{color:var(--muted);font-size:.78rem;margin-top:1px}
/* センチネル&スピナー */
.feed__sentinel{
  display:flex;align-items:center;justify-content:center;gap:8px;
  padding:14px;color:var(--muted);font-size:.82rem;
  border-top:1px solid #232c42;
}
.feed__sentinel.is-done .spinner{display:none}
.spinner{
  width:15px;height:15px;border-radius:50%;
  border:2px solid #344;border-top-color:var(--accent);
  animation:spin .7s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){
  .spinner{animation-duration:1.4s}
  .card{animation:none}
}
JavaScript
// IntersectionObserver で末尾検知 → 擬似データを追加読み込み
const list = document.getElementById('list');
const sentinel = document.getElementById('sentinel');
const countEl = document.getElementById('count');

if (list && sentinel) {
  const NAMES = ['さくら', 'ハル', 'ミナ', 'レン', 'ユウ', 'カイ', 'ノア', 'リオ', 'アヤ', 'ソラ'];
  const ACTS = ['が投稿にいいねしました', 'をフォローしました', 'がコメントしました', 'を共有しました', 'が画像を追加しました'];
  const COLORS = ['#38bdf8', '#a78bfa', '#f472b6', '#34d399', '#fbbf24', '#fb7185'];
  const PER_PAGE = 6;
  const MAX = 48; // 上限に達したら停止
  let loaded = 0;
  let busy = false;

  const minsAgo = (i) => (i < 60 ? `${i}分前` : `${Math.floor(i / 60)}時間前`);

  const addBatch = () => {
    if (busy || loaded >= MAX) return;
    busy = true;
    // 読み込み遅延を擬似的に再現
    setTimeout(() => {
      const frag = document.createDocumentFragment();
      for (let i = 0; i < PER_PAGE && loaded < MAX; i++, loaded++) {
        const name = NAMES[loaded % NAMES.length];
        const color = COLORS[loaded % COLORS.length];
        const li = document.createElement('li');
        li.className = 'card';
        li.innerHTML = `
          <span class="card__avatar" style="background:${color}">${name[0]}</span>
          <span class="card__body">
            <span class="card__name">${name}さん</span>
            <span class="card__meta">${ACTS[loaded % ACTS.length]}・${minsAgo(loaded + 1)}</span>
          </span>`;
        frag.appendChild(li);
      }
      // センチネルは常に末尾に保つため、その手前へ挿入
      list.insertBefore(frag, sentinel);
      if (countEl) countEl.textContent = `${loaded}件`;

      if (loaded >= MAX) {
        sentinel.classList.add('is-done');
        sentinel.querySelector('span:last-child').textContent = 'すべて読み込みました';
        io.disconnect();
      }
      busy = false;
    }, 600);
  };

  // 末尾センチネルが見えたら次を読む(root はスクロール要素 list)
  const io = new IntersectionObserver((entries) => {
    entries.forEach((e) => { if (e.isIntersecting) addBatch(); });
  }, { root: list, threshold: 0.1 });
  io.observe(sentinel);

  addBatch(); // 初回ロード
}

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「無限スクロール」の効果を追加してください。

# 追加してほしい効果
無限スクロール(UIコンポーネント)
IntersectionObserverで末尾到達を検知し追加読み込みするフィード。スピナーと上限制御付きで、タイムラインや一覧表示に使えます。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 無限スクロール:IntersectionObserver で末尾到達を検知し追加読み込み -->
<div class="feed">
  <header class="feed__head">
    <h2 class="feed__title">アクティビティ</h2>
    <span class="feed__count" id="count">0件</span>
  </header>

  <ul class="feed__list" id="list">
    <!-- 監視対象(スクロール要素の内側)。ここが見えたら次を読み込む -->
    <li class="feed__sentinel" id="sentinel">
      <span class="spinner" aria-hidden="true"></span>
      <span>読み込み中…</span>
    </li>
  </ul>
</div>

【CSS】
:root{
  --bg:#0e1320;
  --card:#181f30;
  --accent:#38bdf8;
  --text:#e4e9f3;
  --muted:#8a95ad;
}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;
  display:grid;place-items:center;padding:18px;
  font-family:"Segoe UI",system-ui,sans-serif;color:var(--text);
  background:radial-gradient(700px 360px at 50% -10%,#1a2740,transparent),var(--bg);
}
.feed{
  width:min(420px,100%);height:340px;
  display:flex;flex-direction:column;
  background:var(--card);border:1px solid #232c42;border-radius:18px;
  overflow:hidden;box-shadow:0 26px 50px -26px rgba(0,0,0,.7);
}
.feed__head{
  display:flex;align-items:center;justify-content:space-between;
  padding:16px 18px;border-bottom:1px solid #232c42;
  background:linear-gradient(180deg,rgba(56,189,248,.08),transparent);
}
.feed__title{margin:0;font-size:1.05rem}
.feed__count{color:var(--muted);font-size:.8rem;font-variant-numeric:tabular-nums}
/* スクロール領域 */
.feed__list{
  flex:1;overflow-y:auto;margin:0;padding:8px;list-style:none;
  scrollbar-width:thin;scrollbar-color:#384663 transparent;
}
.feed__list::-webkit-scrollbar{width:8px}
.feed__list::-webkit-scrollbar-thumb{background:#384663;border-radius:8px}
.card{
  display:flex;align-items:center;gap:12px;
  padding:11px 12px;margin:6px 4px;border-radius:12px;
  background:#1f2840;
  animation:rise .35s ease both;
}
.card__avatar{
  flex:none;width:38px;height:38px;border-radius:50%;
  display:grid;place-items:center;font-weight:700;color:#0e1320;
}
.card__body{min-width:0}
.card__name{font-weight:600;font-size:.9rem}
.card__meta{color:var(--muted);font-size:.78rem;margin-top:1px}
/* センチネル&スピナー */
.feed__sentinel{
  display:flex;align-items:center;justify-content:center;gap:8px;
  padding:14px;color:var(--muted);font-size:.82rem;
  border-top:1px solid #232c42;
}
.feed__sentinel.is-done .spinner{display:none}
.spinner{
  width:15px;height:15px;border-radius:50%;
  border:2px solid #344;border-top-color:var(--accent);
  animation:spin .7s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){
  .spinner{animation-duration:1.4s}
  .card{animation:none}
}

【JavaScript】
// IntersectionObserver で末尾検知 → 擬似データを追加読み込み
const list = document.getElementById('list');
const sentinel = document.getElementById('sentinel');
const countEl = document.getElementById('count');

if (list && sentinel) {
  const NAMES = ['さくら', 'ハル', 'ミナ', 'レン', 'ユウ', 'カイ', 'ノア', 'リオ', 'アヤ', 'ソラ'];
  const ACTS = ['が投稿にいいねしました', 'をフォローしました', 'がコメントしました', 'を共有しました', 'が画像を追加しました'];
  const COLORS = ['#38bdf8', '#a78bfa', '#f472b6', '#34d399', '#fbbf24', '#fb7185'];
  const PER_PAGE = 6;
  const MAX = 48; // 上限に達したら停止
  let loaded = 0;
  let busy = false;

  const minsAgo = (i) => (i < 60 ? `${i}分前` : `${Math.floor(i / 60)}時間前`);

  const addBatch = () => {
    if (busy || loaded >= MAX) return;
    busy = true;
    // 読み込み遅延を擬似的に再現
    setTimeout(() => {
      const frag = document.createDocumentFragment();
      for (let i = 0; i < PER_PAGE && loaded < MAX; i++, loaded++) {
        const name = NAMES[loaded % NAMES.length];
        const color = COLORS[loaded % COLORS.length];
        const li = document.createElement('li');
        li.className = 'card';
        li.innerHTML = `
          <span class="card__avatar" style="background:${color}">${name[0]}</span>
          <span class="card__body">
            <span class="card__name">${name}さん</span>
            <span class="card__meta">${ACTS[loaded % ACTS.length]}・${minsAgo(loaded + 1)}</span>
          </span>`;
        frag.appendChild(li);
      }
      // センチネルは常に末尾に保つため、その手前へ挿入
      list.insertBefore(frag, sentinel);
      if (countEl) countEl.textContent = `${loaded}件`;

      if (loaded >= MAX) {
        sentinel.classList.add('is-done');
        sentinel.querySelector('span:last-child').textContent = 'すべて読み込みました';
        io.disconnect();
      }
      busy = false;
    }, 600);
  };

  // 末尾センチネルが見えたら次を読む(root はスクロール要素 list)
  const io = new IntersectionObserver((entries) => {
    entries.forEach((e) => { if (e.isIntersecting) addBatch(); });
  }, { root: list, threshold: 0.1 });
  io.observe(sentinel);

  addBatch(); // 初回ロード
}

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。