More Layouts Snippets
Infinite Scroll HTML CSS JS — IntersectionObserver Feed
Infinite Scroll · Layouts · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Infinite Scroll — How to Build an IntersectionObserver-Based Infinite Scroll Feed in JavaScript

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:
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
- 1See 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.
- 2Scroll 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.
- 3See 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.
- 4Reach the endAfter 5 batches (30 articles), a "You've reached the end" message appears and the observer disconnects. No further scroll events fire.
- 5Connect 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.
- 6Customize 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
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.