About this snippet

A feed of update cards that loads more as you scroll. When the bottom of the list comes into view a spinner shows, the next batch arrives, and the spinner steps aside for the new cards. After the last page the feed says so and stops asking.

There is no scroll listener. An IntersectionObserver watches a sentinel element after the list and fires when it enters the viewport, which is exactly the moment to fetch. A rootMargin on the observer starts the fetch a little early so the new cards are usually there before the visitor reaches the gap. A loading flag stops a second request while one is in flight, and once the final page has rendered the observer is disconnected.

The fetch is simulated with a timeout that builds ten cards per page. Replace the body of loadPage with a real request that returns a page of items and the rest of the script stays the same.

Size
3.8 KB
Uses
HTML, CSS, JavaScript
Loads
Nothing external
Views
42
<main class="feed">
  <header>
    <h1>Activity</h1>
    <p>Scroll down. Each batch of ten loads as the end of the list comes into view.</p>
  </header>
  <div class="list" id="list"></div>
  <div class="sentinel" id="sentinel" aria-live="polite">
    <div class="spinner" id="spinner" role="status" aria-label="Loading more" hidden></div>
    <p class="end" id="end" hidden>You are all caught up.</p>
  </div>
</main>
*, *::before, *::after {
  box-sizing: border-box;
}

body {
  font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  margin: 0;
  background: #f1f5f9;
  color: #0f172a;
}

.feed {
  max-width: 520px;
  margin: 0 auto;
  padding: 32px 20px 60px;
}

header h1 {
  font-size: 1.5rem;
  margin: 0 0 6px;
}

header p {
  margin: 0 0 24px;
  color: #64748b;
  line-height: 1.5;
}

.list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.card {
  display: flex;
  gap: 14px;
  align-items: center;
  background: #ffffff;
  padding: 16px;
  border-radius: 14px;
  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 20px -14px rgba(15, 23, 42, 0.25);
  animation: rise 0.35s ease both;
}

@keyframes rise {
  from { opacity: 0; transform: translateY(10px); }
}

.avatar {
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: #ffffff;
  font-size: 0.8125rem;
  font-weight: 700;
}

.card strong {
  display: block;
  font-size: 0.9375rem;
}

.card div span {
  font-size: 0.8125rem;
  color: #64748b;
}

.sentinel {
  display: flex;
  justify-content: center;
  padding: 24px 0;
  min-height: 72px;
}

.spinner {
  width: 28px;
  height: 28px;
  border: 3px solid #e2e8f0;
  border-top-color: #2563eb;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

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

.end {
  margin: 0;
  font-size: 0.875rem;
  color: #94a3b8;
}
const list = document.getElementById("list");
const sentinel = document.getElementById("sentinel");
const spinner = document.getElementById("spinner");
const end = document.getElementById("end");

const names = ["Ava Chen", "Noah Patel", "Mia Okafor", "Liam Rossi", "Zoe Martin", "Ethan Kim", "Isla Novak", "Leo Haddad", "Ruby Sato", "Omar Diaz"];
const actions = ["pushed 3 commits", "opened a pull request", "left a review", "closed an issue", "deployed to production", "updated the docs", "merged a branch", "added a teammate", "changed a setting", "restored a backup"];
const colors = ["#2563eb", "#7c3aed", "#059669", "#ea580c", "#db2777", "#0891b2"];

const pageSize = 10;
const lastPage = 5;
let page = 0;
let loading = false;

function loadPage() {
  loading = true;
  spinner.hidden = false;
  setTimeout(function() {
    page++;
    for (let i = 0; i < pageSize; i++) {
      const n = (page - 1) * pageSize + i;
      const card = document.createElement("article");
      card.className = "card";
      card.style.animationDelay = (i * 40) + "ms";
      const initials = names[n % names.length].split(" ").map(function(part) {
        return part[0];
      }).join("");
      card.innerHTML = '<span class="avatar" style="background:' + colors[n % colors.length] + '">' + initials + '</span><div><strong>' + names[n % names.length] + '</strong><span>' + actions[(n * 3) % actions.length] + ' in project-' + (n + 1) + '</span></div>';
      list.appendChild(card);
    }
    spinner.hidden = true;
    loading = false;
    if (page >= lastPage) {
      observer.disconnect();
      end.hidden = false;
    } else {
      observer.unobserve(sentinel);
      observer.observe(sentinel);
    }
  }, 700);
}

const observer = new IntersectionObserver(function(entries) {
  if (entries[0].isIntersecting && !loading) {
    loadPage();
  }
}, { rootMargin: "0px 0px 200px 0px" });

observer.observe(sentinel);
Esc
↑↓ moveEnter select

Start from a template

Open an .html file, or HTML, CSS, JS, Markdown, Less or TypeScript files. You can also drop them here. Styles and scripts go into their panes.

Share

Source

Keyboard shortcuts

Save