Infinite Scroll HTML CSS JS — IntersectionObserver Feed

Infinite Scroll · Layouts · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

IntersectionObserver sentinel: invisible element below feed triggers load, rootMargin:120px pre-fetch buffer
loading flag: prevents concurrent batch loads while a fetch is in progress
MAX_BATCHES limit: observer.disconnect() called when exhausted — no memory leak, no continued observation
Skeleton placeholders: background-size:200% shimmer CSS animation, removed after simulated API response
DocumentFragment batch append: all cards appended to fragment first, single feedList.appendChild(frag) call
End-of-feed message: hidden element revealed on MAX_BATCHES reached, checkmark icon, clean closure
Article count label: updates after each batch — "N articles" shows total content volume
Zero scroll listeners: entire loading mechanism is IntersectionObserver-based, no main thread polling

About this UI Snippet

Infinite Scroll — How to Build an IntersectionObserver-Based Infinite Scroll Feed in JavaScript

Screenshot of the Infinite Scroll snippet rendered live

Infinite scroll is a feed loading pattern where content loads automatically as the user approaches the bottom of the page, removing the need for "Load More" buttons or pagination. Used by Twitter, Instagram, LinkedIn, and most modern content feeds, it creates an uninterrupted reading experience by fetching the next batch of content just before the user reaches the end of what has already loaded.

The naive implementation — listening to the scroll event and checking scrollTop + clientHeight >= scrollHeight — fires hundreds of times per second during scroll, runs on the main thread, and degrades performance. The modern approach uses IntersectionObserver, which fires asynchronously only when a target element enters or leaves the viewport.

This snippet builds a complete infinite scroll article feed using IntersectionObserver with a sentinel pattern, batch loading with skeleton placeholders, a configurable maximum batch count, and an end-of-feed message — all in plain JavaScript.

The Sentinel Pattern

A sentinel is an invisible element placed after the last rendered card. When the sentinel enters the viewport (becomes visible as the user scrolls down), the IntersectionObserver fires and loads the next batch. When loading is complete, the sentinel is still below the new cards, ready to trigger the next batch when reached again.

The sentinel element: <div class="sentinel" id="sentinel"></div>. It has zero visual size. The observer is created with rootMargin: '120px' — this means the observer fires 120px before the sentinel actually enters the viewport. This gives enough time to generate and inject the new cards so the user never sees a loading gap. The rootMargin acts as a pre-fetch buffer.

IntersectionObserver Setup

const observer = new IntersectionObserver(entries => { if (entries[0].isIntersecting && !loading && batch < MAX_BATCHES) { loadBatch(); } }, { rootMargin: '120px' }); observer.observe(sentinel);

The isIntersecting check fires only when the sentinel is visible (not on exit). The loading flag prevents concurrent batch loads. The batch < MAX_BATCHES check stops loading when all content is exhausted. The observer is disconnected when MAX_BATCHES is reached, since no further loading is needed.

Skeleton Placeholder Pattern

Before each batch of real cards loads, skeleton placeholder cards are injected into the feed. Each skeleton is a <div class="card skeleton"> with grey placeholder elements where the image, title, and meta text would appear. A CSS shimmer animation sweeps a lighter gradient across the placeholders — the same GPU-accelerated background-position technique used by Facebook and LinkedIn.

After a simulated 700ms delay (representing the API response time), the skeleton cards are removed with skeletons.forEach(s => s.remove()) and replaced with the real article cards. In production, the setTimeout would be replaced by an await fetch() call.

Batch Generation

The snippet includes a large titles array (30 entries) and a tags array. Each appendCards(batchIndex) call slices 6 entries from the titles array starting at batchIndex × BATCH_SIZE. Each card is created with document.createElement, populated with title, a formatted date, a reading time estimate, and a randomly selected tag. All 6 cards are appended to the feed via documentFragment for a single reflow rather than 6 individual DOM insertions.

DocumentFragment Batch Append

DOM insertions are expensive because each appendChild triggers a potential reflow. The snippet uses const frag = document.createDocumentFragment() — appending all 6 new cards to the fragment first, then calling feedList.appendChild(frag) once. This batches all 6 DOM insertions into a single reflow, reducing layout thrashing by 6×.

End of Feed

When batch >= MAX_BATCHES, the observer is disconnected with observer.disconnect() and an end-of-feed message is revealed: a checkmark icon with "You've reached the end." This prevents infinite loading when the content pool is exhausted and gives users clear closure. The end message uses endMsg.style.display = 'flex' to reveal the pre-rendered hidden element.

Article Count Label

The feed header shows "N articles" and updates after each batch load: feedCountEl.textContent = Math.min(batch * BATCH_SIZE, totalCards) + ' articles'. This gives users a sense of content volume without exposing implementation details like batch numbers.

Performance vs Scroll Listener

The IntersectionObserver approach fires at most once per batch and runs in a browser-managed background thread. A scroll listener equivalent would fire 60+ times per second during scroll, run synchronously on the main thread, and require throttle() or requestAnimationFrame() wrapping to not degrade scroll performance. IntersectionObserver is the correct tool for any "trigger action when element is visible" use case.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You do not have to reconstruct the batching logic from scratch on your own. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why loadMore disconnects the observer before showing skeletons and reconnects it only after appendCards runs, or how buildCard's use of the pick helper with different offsets keeps the category, author, and time values varied instead of always cycling in lockstep. The same assistant can help you optimize it — ask whether appending each card individually in the loop inside appendCards should instead batch into a single DocumentFragment before one appendChild call, especially once MAX_BATCHES grows much larger. It is just as useful for extending the feed: ask it to wire loadMore to a real paginated fetch call, add a scroll-position-preserving "jump to top" button once several batches have loaded, or introduce a lightweight virtualization layer so very long feeds do not keep every card in the DOM forever. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build an infinite-scroll article feed in plain HTML, CSS, and JavaScript using only the IntersectionObserver API — no scroll event listeners, no libraries.

Requirements:
- A feed container holding an initial batch of article cards rendered synchronously on load, each card showing a category pill, title, excerpt, author avatar with initials, author name, and a relative timestamp.
- A single empty sentinel div placed after the last card, observed by an IntersectionObserver configured with a rootMargin of roughly 120px so loading starts slightly before the sentinel is actually scrolled into view.
- A loadMore function that: checks a loading boolean and a batch counter against a maximum batch count before doing anything; if allowed, disconnects the observer immediately to prevent duplicate triggers; inserts several skeleton placeholder cards plus a spinner, each skeleton using a CSS shimmer animation (a linear-gradient background whose background-position animates across the element); waits on a simulated network delay; then removes the skeletons and spinner, appends the next batch of real cards, updates a visible "N articles" counter, and either reconnects the observer or, if the batch limit is reached, permanently hides the sentinel and reveals a "You've reached the end" message with a checkmark icon.
- Each card must fade in and slide up slightly via a CSS keyframes animation when it is first appended, with a small per-card animation-delay stagger within its batch so cards do not all pop in at once.
- Card content must be generated from a set of arrays (titles, excerpts, categories, authors, timestamps) using a helper that picks an entry by index modulo the array length, so the feed can produce far more unique-looking cards than the arrays have entries.

Step by step

How to Use

  1. 1
    See the initial feedSix article cards load immediately. The feed shows the article title, tag, date, and reading time. The IntersectionObserver is already watching the sentinel below the last card.
  2. 2
    Scroll to load moreScroll toward the bottom of the feed. When the sentinel comes within 120px of the viewport, the observer fires and loads the next batch of 6 cards.
  3. 3
    See skeleton placeholdersJust before new cards appear, grey shimmer skeleton placeholders flash briefly — simulating an API response delay. In production this matches the real network latency.
  4. 4
    Reach the endAfter 5 batches (30 articles), a "You've reached the end" message appears and the observer disconnects. No further scroll events fire.
  5. 5
    Connect to a real APIReplace the setTimeout simulation in loadBatch() with an await fetch() call to your API endpoint. Pass the batch number as a page parameter: /api/articles?page=2&limit=6.
  6. 6
    Customize batch sizeChange BATCH_SIZE (cards per batch) and MAX_BATCHES (total batches before end) at the top of the JS. Adjust rootMargin on the IntersectionObserver to pre-fetch earlier or later.

Real-world uses

Common Use Cases

News Feed & Article Listing Pages
Load articles, blog posts, or news items in batches as users scroll. The sentinel rootMargin pre-fetches the next batch before the user reaches the end of the current one, eliminating any visible loading gap. Replace the card template with your article structure and the setTimeout with a real API fetch.
Photo Gallery & Image Grid Lazy Loading
Extend the pattern to image grids: load image URLs in batches, create img elements with loading="lazy", and observe a sentinel at the bottom of the grid. The IntersectionObserver handles both the batch trigger and browser-native lazy image loading in the same pattern.
Social Media Feed & User Timeline
Build a Twitter/Instagram-style content feed with post cards, avatars, and engagement buttons. Each batch maps to a page of API results. The skeleton placeholders match the social feed loading pattern users expect. Pair with a social post card template for the card design.
E-commerce Product Catalog Browsing
Replace "Load More" buttons with automatic product batch loading. Product grids with 4–6 items per row load naturally as users browse. The rootMargin pre-fetch means the next product row is already in DOM when users finish looking at the current last row.
IntersectionObserver Study Reference
Study the complete IntersectionObserver sentinel pattern: observer creation, rootMargin pre-fetch buffer, isIntersecting check, loading flag, disconnect on exhaustion, and DocumentFragment batch append. All techniques apply directly to any "load on scroll" or "lazy render" use case.
Dashboard Activity Log & Audit Trail
Load audit log entries, activity events, or transaction histories in reverse-chronological batches. The sentinel pattern handles large logs cleanly — load 20 entries at a time rather than paginating. Users scroll through history naturally without clicking through pages.

Got questions?

Frequently Asked Questions

The sentinel is an invisible zero-height element placed after the last loaded card. An IntersectionObserver watches it — when it enters the viewport, the observer fires and triggers the next batch load. Unlike scroll event listeners (which fire 60+ times per second on the main thread and require throttling), IntersectionObserver fires asynchronously at most once per batch, runs off the main thread, and has no performance cost during scrolling. It is the browser-native solution for scroll-triggered content loading.

rootMargin expands the observation boundary beyond the actual viewport. With rootMargin: "120px", the observer fires when the sentinel is 120px below the visible viewport edge — before the user has actually reached it. This pre-fetch buffer gives the batch loading time to complete before the user reaches the current last card. Without rootMargin, users would see a brief empty gap at the bottom while cards loaded. Increase rootMargin for slower APIs (more buffer time) or decrease for very fast APIs.

Replace the setTimeout simulation in loadBatch() with: const res = await fetch(/api/posts?page=${batch + 1}&limit=${BATCH_SIZE}); const posts = await res.json(); if (!posts.length) { observer.disconnect(); endMsg.style.display = "flex"; return; } const frag = document.createDocumentFragment(); posts.forEach(post => { const card = createCard(post); frag.appendChild(card); }); feedList.appendChild(frag); batch++;. The API response length check replaces the MAX_BATCHES limit.

Each skeleton card uses background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%) with background-size: 200% on the placeholder elements (title bar, image area, meta row). A CSS @keyframes animates background-position from "200% 0" to "-200% 0" over 1.4 seconds. This sweeps a lighter highlight across the grey skeleton shape. Because background-position changes run on the GPU compositor, the animation runs at 60fps without affecting main thread performance.

Check the response length: if (posts.length === 0 || posts.length < BATCH_SIZE) { observer.disconnect(); endMsg.style.display = "flex"; return; }. If the API returns fewer items than BATCH_SIZE, it means you've reached the last partial page — show the end message. Also check for an explicit "hasMore: false" field in your API response if your backend provides pagination metadata. Always call observer.disconnect() when done to free browser memory.

Yes. The JSX, Vue, Angular, and Tailwind exports convert it automatically. In React, create the IntersectionObserver in useEffect, observe the sentinel div via a ref, and append items with setState; return observer.disconnect() as the cleanup so the observer does not leak between renders.