無限スクロール
IntersectionObserverで末尾到達を検知し追加読み込みするフィード。スピナーと上限制御付きで、タイムラインや一覧表示に使えます。
ライブデモ
使用例(お題: アイドルグループ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。