Infinite Scroll Table — Lazy-Load Rows HTML CSS JS

Infinite Scroll Table · Tables · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

IntersectionObserver sentinel
Loads the next page when a bottom sentinel scrolls into view — no scroll-listener math.
Scoped to the container
The observer's root is the scroll box, with rootMargin to preload before the end.
Guarded paging
A loading flag and end check prevent duplicate or past-the-end fetches.
Offset/limit contract
fetchPage(offset, limit) mirrors a real backend, so swapping in fetch is one line.
Append, not re-render
New rows are appended so existing rows and scroll position are preserved.
Loading + end states
A spinner while fetching, an explicit end message, and the observer disconnects when done.
Sticky header + count
The header pins while scrolling and a loaded/total count shows progress.
Row fade-in & no library
New rows fade in; pure HTML/CSS/JS with zero dependencies.

About this UI Snippet

Infinite Scroll Table — Lazy-Load Pages of Rows with a Sentinel Observer

Screenshot of the Infinite Scroll Table snippet rendered live

An infinite-scroll table loads rows in pages as the user scrolls toward the bottom, instead of fetching thousands at once — the right pattern for long transaction logs, feeds, and search results where loading everything upfront is slow and wasteful. This snippet builds it in plain HTML, CSS, and vanilla JavaScript with an IntersectionObserver sentinel, a sticky header, a loading spinner, and a proper end state — no library.

A sentinel, not a scroll listener

The next page loads when a sentinel element at the bottom of the list scrolls into view. An IntersectionObserver watches that sentinel (scoped to the scroll container via root, with a rootMargin so loading starts a bit before the user hits the very end). This is the modern replacement for listening to scroll and computing scrollTop + clientHeight >= scrollHeight on every frame — the observer fires only when the sentinel actually appears, off the main thread, so it's both simpler and far more efficient.

Guarded, paged loading

loadMore is guarded by a loading flag and a "have we loaded everything?" check, so overlapping triggers (fast scrolling, the observer firing repeatedly) can't fire duplicate fetches or load past the end. Each call fetches the next PAGE rows from the current offset and appends them. This offset/limit paging is exactly the contract a real backend exposes, so swapping the simulated fetchPage for a real fetch('/api/rows?offset=…&limit=…') is a one-line change.

Appending, not re-rendering

New rows are appended with insertAdjacentHTML('beforeend', …) rather than rebuilding the whole table, so already-rendered rows (and the user's scroll position) are untouched as the list grows. A subtle fade-in animation on new rows signals that more arrived. This append-only approach is what keeps infinite scroll smooth as the dataset reaches hundreds of rows.

Loading and end states

While a page is in flight, a spinner sentinel reads "Loading more…"; when the last page arrives, the sentinel is hidden, an explicit "You have reached the end" message shows, and the observer is disconnected so it stops watching. Telling the user they've reached the end (and stopping the machinery) is the finishing touch that a bare infinite scroll often forgets, leaving a spinner that never resolves.

A sticky header and a count

The header stays pinned with position: sticky as rows scroll beneath it, and a "loaded of total" count gives a sense of progress. The whole thing is a drop-in, dependency-free reference for the sentinel-observer infinite-scroll pattern, applicable to tables, feeds, or any long list. One trade-off worth knowing: because every loaded row stays in the DOM forever, a list that grows into the thousands will eventually slow down scrolling and memory use — past a few hundred rows it's worth combining this sentinel-loading pattern with row virtualization (rendering only the rows currently in or near the viewport) rather than relying on lazy-loading alone.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You do not need to trace the loading guards and observer wiring line by line yourself. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why loadMore checks both the loading flag and loaded >= TOTAL before firing, or why the observer is scoped to the scroll container with root and rootMargin rather than watching the whole viewport. The same assistant can help optimize it — ask whether the append-only approach in appendRows will eventually slow down scrolling once thousands of rows have accumulated in the tbody, and whether row virtualization should replace or supplement the sentinel pattern past a certain row count. It is just as useful for extending the table: ask it to add a real fetch-based fetchPage that talks to a paginated REST endpoint, column sorting that resets and reloads from offset zero, or a scroll-to-top button that appears once enough rows have loaded. 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 data table in plain HTML, CSS, and JavaScript using only the IntersectionObserver API for pagination — no scroll event listeners, no libraries.

Requirements:
- A table with a sticky thead (position: sticky, top: 0) inside a scrollable container with a fixed max-height and overflow-y auto, so the header stays visible while rows scroll beneath it.
- A sentinel element placed after the last row, inside the same scroll container, showing a spinner and "Loading more..." text while a page is in flight.
- An IntersectionObserver whose root is the scroll container (not the viewport) and whose rootMargin is a positive value like 120px, so the next page starts loading slightly before the sentinel is actually visible, avoiding a dead-stop pause.
- A paged data-fetching function with the signature fetchPage(offset, limit) that returns a promise, mirroring a real REST API's offset/limit query parameters, simulated here with a setTimeout delay instead of a real network call.
- A loadMore function guarded by two conditions: a boolean loading flag (to prevent duplicate fetches from repeated observer firings) and a check that the loaded count has not already reached the total (to prevent fetching past the end).
- New rows must be appended to the existing tbody using insertAdjacentHTML with beforeend (not by re-rendering the whole table), so already-rendered rows and the user's scroll position are undisturbed as more rows arrive.
- When the last page loads, hide the loading sentinel, reveal a distinct "You have reached the end" message, and call observer.disconnect() so the observer stops watching entirely.
- A header count label that updates to show how many rows have loaded out of the known total after every page.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA transactions table renders its first page and shows a "Loading more…" sentinel.
  2. 2
    Scroll downAs you near the bottom, the next page of rows loads and appends automatically.
  3. 3
    Watch the countThe header shows "loaded of total" so progress is visible.
  4. 4
    Reach the endAfter the last page, an end message shows and loading stops.
  5. 5
    Wire to your APIReplace fetchPage with a real fetch('/api/rows?offset=…&limit=…') call.
  6. 6
    Tune pagingAdjust PAGE size and the observer rootMargin for earlier or later loading.

Real-world uses

Common Use Cases

Transaction and order logs
Lazy-load long financial lists — pair with a data table for search and sort.
Feeds and activity streams
Page through events as the user scrolls, alongside an activity feed.
Search results
Load more matches on demand instead of all at once.
Admin record browsers
Browse large datasets without a heavy initial load, next to a filterable table.
Audit trails and history
Stream long histories in pages.
Learning the sentinel pattern
A reference for IntersectionObserver infinite scroll — compare with infinite scroll for cards.

Got questions?

Frequently Asked Questions

A scroll listener fires constantly and forces you to compute scrollTop + clientHeight >= scrollHeight on every event, which is wasteful and runs on the main thread. An IntersectionObserver watching a bottom sentinel fires only when that element actually scrolls into view, off the main thread, and a rootMargin lets you start loading before the user hits the very end. It's simpler, faster, and the modern standard for infinite scroll.

loadMore is guarded by a loading boolean and a loaded >= TOTAL check. While a fetch is in flight, loading is true so repeated observer firings (common during fast scrolling) return early; once all rows are loaded it returns early too and the observer is disconnected. This prevents duplicate fetches and loading past the end — the two classic infinite-scroll bugs.

Replace the simulated fetchPage(offset, limit) — which resolves after a timeout — with a real call like fetch('/api/rows?offset=' + offset + '&limit=' + limit).then(r => r.json()). The rest of the code already speaks the offset/limit paging contract that REST APIs expose, appends whatever rows come back, and stops when fewer than a full page (or the known total) is returned.

Rebuilding the whole tbody on each page would discard and recreate already-visible rows, risk losing the scroll position, and get slower as the list grows. insertAdjacentHTML('beforeend', …) appends only the new rows, leaving existing DOM and scroll untouched — which keeps infinite scroll smooth even after hundreds of rows. A fade-in animation marks the newly added rows.

Hold the rows and a loading flag in state, render the tbody from the array, and set up the IntersectionObserver in a useEffect (React), onMounted/onUnmounted (Vue), or ngAfterViewInit/ngOnDestroy (Angular) on a ref to the sentinel, disconnecting on cleanup. On intersect, fetch the next page and append to state. The observer + paging logic is framework-agnostic.