UI Components Medium
Infinite Feed Sentinel
Append feed cards when an IntersectionObserver sentinel becomes visible.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #0b1020;
color: #eef2ff;
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
button { font: inherit; }
.demo {
width: min(720px, 100%);
margin: auto;
background: #121a2d;
border: 1px solid #263555;
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0004;
}
.feed-head h1 { margin: 0 0 .25rem; font-size: 1.35rem; letter-spacing: -.02em; }
.muted { color: #a8b5d1; margin: 0; font-size: .88rem; }
.counter {
margin: .8rem 0 0;
font-size: .78rem;
color: #78a9ff;
font-variant-numeric: tabular-nums;
}
.scrollbox {
height: 340px;
margin-top: 1rem;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid #2b3d62;
border-radius: 16px;
background: #0e1526;
padding: .75rem;
scrollbar-width: thin;
scrollbar-color: #34497a transparent;
}
.scrollbox:focus-visible { outline: 2px solid #78a9ff; outline-offset: 2px; }
.items { display: grid; gap: .6rem; margin: 0; padding: 0; list-style: none; }
.card {
display: grid;
grid-template-columns: 38px 1fr;
gap: .75rem;
background: linear-gradient(180deg, #1a2540, #162036);
border: 1px solid #2b3d62;
border-radius: 14px;
padding: .8rem .9rem;
animation: rise .32s ease both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.avatar {
width: 38px; height: 38px;
border-radius: 50%;
display: grid; place-items: center;
font-weight: 700; font-size: .8rem;
color: #0b1020;
}
.card h2 { margin: 0; font-size: .92rem; }
.card .meta { margin: .1rem 0 .35rem; font-size: .72rem; color: #8fa0c4; }
.card p.body { margin: 0; font-size: .85rem; color: #c6d2ea; }
.sentinel {
height: 52px;
display: flex;
align-items: center;
justify-content: center;
gap: .55rem;
color: #9eb1d4;
font-size: .8rem;
}
.sentinel[data-state="done"] .spinner { display: none; }
.spinner {
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid #2b3d62;
border-top-color: #78a9ff;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
.feed-actions { margin-top: 1rem; }
.btn {
background: #1a2540;
color: #eef2ff;
border: 1px solid #2b3d62;
border-radius: 10px;
padding: .5rem .9rem;
font-size: .85rem;
cursor: pointer;
}
.btn:hover:not(:disabled) { border-color: #78a9ff; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn:focus-visible { outline: 2px solid #78a9ff; outline-offset: 2px; }
.ghost-btn { background: transparent; }
.hint { font-size: .78rem; color: #8fa0c4; }
@media (prefers-reduced-motion: reduce) {
.card { animation: none; }
.spinner { animation-duration: 2.4s; }
}/* Infinite Feed Sentinel — IntersectionObserver-driven pagination. */
const root = document.querySelector("#feed");
const list = document.querySelector("#feed-items");
const sentinel = document.querySelector("#feed-sentinel");
const label = document.querySelector("#sentinel-label");
const countEl = document.querySelector("#count");
const pageEl = document.querySelector("#page");
const moreBtn = document.querySelector("#more");
const resetBtn = document.querySelector("#reset");
const PAGE_SIZE = 8;
const TOTAL_PAGES = 6;
const LATENCY = 450;
const NAMES = [
"Ada Lovelace", "Grace Hopper", "Alan Turing", "Radia Perlman",
"Ken Thompson", "Barbara Liskov", "Linus Torvalds", "Margaret Hamilton",
];
const VERBS = [
"shipped a patch to", "opened a discussion on", "reviewed changes in",
"benchmarked", "documented", "refactored", "deployed", "triaged issues in",
];
const SUBJECTS = [
"the scheduler", "the render pipeline", "the auth gateway", "the cache layer",
"the query planner", "the design tokens", "the CI matrix", "the edge worker",
];
let page = 0;
let loading = false;
let exhausted = false;
const hues = [212, 265, 155, 24, 340, 190];
function initials(name) {
return name.split(" ").map((w) => w[0]).join("");
}
function buildCard(index) {
const name = NAMES[index % NAMES.length];
const hue = hues[index % hues.length];
const li = document.createElement("li");
li.className = "card";
const avatar = document.createElement("div");
avatar.className = "avatar";
avatar.style.background = `linear-gradient(140deg, hsl(${hue} 85% 72%), hsl(${hue + 30} 80% 58%))`;
avatar.textContent = initials(name);
avatar.setAttribute("aria-hidden", "true");
const main = document.createElement("div");
const h2 = document.createElement("h2");
h2.textContent = name;
const meta = document.createElement("p");
meta.className = "meta";
meta.textContent = `post #${index + 1} · ${index * 3 + 2} min ago`;
const body = document.createElement("p");
body.className = "body";
body.textContent = `${VERBS[index % VERBS.length]} ${SUBJECTS[(index * 5) % SUBJECTS.length]}.`;
main.append(h2, meta, body);
li.append(avatar, main);
return li;
}
function setStatus() {
countEl.textContent = String(list.children.length);
pageEl.textContent = String(page);
moreBtn.disabled = exhausted || loading;
if (exhausted) {
sentinel.dataset.state = "done";
label.textContent = "End of feed — no more posts.";
} else {
sentinel.dataset.state = loading ? "loading" : "idle";
label.textContent = loading ? "Loading more…" : "Scroll for more";
}
}
function fetchPage(n) {
// Stand-in for a network request; swap for fetch() in production.
return new Promise((resolve) => {
setTimeout(() => {
const start = n * PAGE_SIZE;
resolve(Array.from({ length: PAGE_SIZE }, (_, i) => start + i));
}, LATENCY);
});
}
async function loadNext() {
if (loading || exhausted) return;
loading = true;
setStatus();
const indexes = await fetchPage(page);
const frag = document.createDocumentFragment();
indexes.forEach((i) => frag.append(buildCard(i)));
list.append(frag);
page += 1;
loading = false;
exhausted = page >= TOTAL_PAGES;
setStatus();
// If the sentinel is still visible (short list, tall viewport), keep going.
if (!exhausted && sentinelVisible()) loadNext();
}
function sentinelVisible() {
const r = sentinel.getBoundingClientRect();
const b = root.getBoundingClientRect();
return r.top < b.bottom && r.bottom > b.top;
}
function reset() {
list.replaceChildren();
page = 0;
loading = false;
exhausted = false;
root.scrollTop = 0;
setStatus();
loadNext();
}
if ("IntersectionObserver" in window) {
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) loadNext();
},
{ root, rootMargin: "160px 0px", threshold: 0 },
);
io.observe(sentinel);
} else {
// Fallback: scroll-position check.
root.addEventListener("scroll", () => {
if (root.scrollTop + root.clientHeight >= root.scrollHeight - 160) loadNext();
});
}
moreBtn.addEventListener("click", loadNext);
resetBtn.addEventListener("click", reset);
setStatus();
loadNext();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite Feed Sentinel</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Infinite Feed Sentinel">
<header class="feed-head">
<h1>Infinite feed</h1>
<p class="muted">New pages are fetched when the sentinel row scrolls into view.</p>
<p class="counter" role="status" aria-live="polite">
<span id="count">0</span> posts · page <span id="page">0</span>
</p>
</header>
<div id="feed" class="scrollbox" tabindex="0" aria-label="Activity feed" role="region">
<ul id="feed-items" class="items"></ul>
<div id="feed-sentinel" class="sentinel">
<span class="spinner" aria-hidden="true"></span>
<span id="sentinel-label">Loading more…</span>
</div>
</div>
<div class="row feed-actions">
<button id="more" type="button" class="btn">Load more</button>
<button id="reset" type="button" class="btn ghost-btn">Reset feed</button>
<span class="hint">Keyboard fallback for when auto-loading is unavailable.</span>
</div>
</main>
<script src="script.js"></script>
</body>
</html>import { useMemo, useState } from "react";
export function VirtualScrollInfiniteFeed() {
const [offset, setOffset] = useState(0);
const rows = useMemo(() => Array.from({ length: 20 }, (_, index) => index + offset), [offset]);
return (
<section className="demo">
<h2>Infinite Feed Sentinel</h2>
<div
style={{ height: 260, overflow: "auto" }}
onScroll={(event) => setOffset(Math.floor(event.currentTarget.scrollTop / 42))}
>
{rows.map((row) => (
<div key={row} style={{ height: 42, padding: 10, borderBottom: "1px solid #2b3d62" }}>
Row {row + 1}
</div>
))}
</div>
</section>
);
}Infinite Feed Sentinel
Append feed cards when an IntersectionObserver sentinel becomes visible.
Support notes
Manual windowing keeps DOM work proportional to the viewport. IntersectionObserver is used for the feed sentinel; row heights are intentionally explicit so the math stays predictable.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.