How to Implement Infinite Scrolling for the Exercise Browser

Add a sentinel element to index.html, use the Intersection Observer API to detect when users scroll near the bottom, and slice the static data/exercises.json file into pages to append new cards without reloading the entire list.

The hasaneyldrm/exercises-dataset repository provides a static exercise browser that renders exercises from a local JSON file. To implement infinite scrolling for the exercise browser while maintaining the static-site architecture, you will paginate the dataset client-side using a sentinel-based detection system.

Architecture Overview

The implementation requires three core components working together. First, index.html provides the container (#exercise-list) and a hidden sentinel element that marks the scroll trigger. Second, data/exercises.json serves as the static data source, which the client fetches once and caches in memory. Third, scripts/infiniteScroll.js (a new file you will create) handles pagination logic, scroll detection, and DOM manipulation.

This approach avoids server-side changes by treating the static JSON file as a paginated API, slicing the array in memory as the user scrolls.

Setting Up the HTML Sentinel

The infinite scroll mechanism relies on a sentinel element placed immediately after the exercise list. When this sentinel enters the viewport, it triggers the next data load.

Update your index.html to include the container and sentinel:

<body>
  <main id="exercise-list"></main>
  <!-- Sentinel that will be observed -->
  <div id="sentinel"></div>

  <script src="scripts/infiniteScroll.js"></script>
</body>

The #sentinel div remains empty and hidden (or minimally styled) while the #exercise-list container holds all rendered exercise cards.

Fetching and Caching the Dataset

Because the repository is client-side only, the "server" is simulated by loading the entire data/exercises.json file once, then serving slices from an in-memory array.

Create scripts/infiniteScroll.js and initialize the pagination state:

// Path to the static JSON file
const EXERCISES_URL = '../data/exercises.json';
const PAGE_SIZE = 20;          // Number of exercises per request
let allExercises = [];         // Holds the full dataset after first load
let offset = 0;                // Current cursor

// Load the dataset once on startup
fetch(EXERCISES_URL)
  .then(res => res.json())
  .then(data => {
    allExercises = data;
    loadNextPage();        // Render the first page
    observeSentinel();     // Start listening for scroll events
  })
  .catch(err => console.error('Failed to load exercises:', err));

Caching the full dataset in allExercises ensures that subsequent page loads are instantaneous, as no additional network requests are required after the initial fetch.

Detecting the Scroll Position

Instead of attaching expensive scroll event listeners to the window, use the Intersection Observer API to detect when the sentinel element becomes visible. This approach is more performant and works regardless of scroll container context.

Implement the observer function:

function observeSentinel() {
  const sentinel = document.getElementById('sentinel');
  const observer = new IntersectionObserver(entries => {
    if (entries[0].isIntersecting) {
      loadNextPage();
    }
  });
  observer.observe(sentinel);
}

The observer watches the #sentinel div and fires loadNextPage() whenever the element enters the viewport, indicating the user has scrolled to the bottom of the current list.

Rendering and Appending Items

When triggered, the pagination logic slices the next chunk of exercises from the cached array and appends them to the DOM without re-rendering existing content.

Add the rendering functions to scripts/infiniteScroll.js:

// Render a slice of exercises into the DOM
function renderExercises(slice) {
  const container = document.getElementById('exercise-list');
  slice.forEach(ex => {
    const card = document.createElement('article');
    card.className = 'exercise-card';
    card.innerHTML = `
      <h2>${ex.title}</h2>
      <p>${ex.description}</p>`;
    container.appendChild(card);
  });
}

// Load the next page and render it
function loadNextPage() {
  if (offset >= allExercises.length) return; // No more items

  const nextSlice = allExercises.slice(offset, offset + PAGE_SIZE);
  renderExercises(nextSlice);
  offset += PAGE_SIZE;
}

The loadNextPage() function guards against over-fetching by checking offset against allExercises.length, automatically stopping when the end of the dataset is reached.

Optional Enhancements

You can extend the basic implementation with several UX improvements:

  • Loading indicators: Add a spinner inside the sentinel element that displays while loadNextPage() processes, removing it once new cards are appended.
  • Persistence: Store the current offset in localStorage during the loadNextPage() execution, then check for this value on initial load to restore the user's scroll position after a refresh.
  • Debouncing: Wrap the Intersection Observer callback with a debounce mechanism if you increase PAGE_SIZE significantly, preventing rapid consecutive renders when the sentinel remains in view during heavy DOM manipulation.

Summary

  • Use the Intersection Observer API instead of scroll event listeners for better performance and cleaner code.
  • Place a sentinel element (#sentinel) after the last card in index.html to act as the scroll trigger.
  • Slice the static data/exercises.json into managable pages (default 20 items) using Array.prototype.slice() to simulate API pagination.
  • Append new cards to #exercise-list incrementally without re-rendering the entire list, preserving scroll position and minimizing DOM reflow.

Frequently Asked Questions

Why should I use Intersection Observer instead of scroll event listeners?

The Intersection Observer API offloads scroll position calculations to the browser engine, providing better performance than manually checking window.scrollY or element.scrollTop on every frame. It also handles edge cases like scrollable containers that are not the main viewport, and automatically manages threshold detection for when the sentinel becomes visible.

How do I handle the end of the dataset?

The loadNextPage() function includes a guard clause (if (offset >= allExercises.length) return;) that stops execution when the offset exceeds the total array length. You can extend this by hiding the sentinel element or displaying a "No more exercises" message when the final slice is rendered.

Can I adapt this code for a real backend API?

Yes. Replace the fetch(EXERCISES_URL) logic with a paginated endpoint (e.g., /api/exercises?offset=${offset}&limit=${PAGE_SIZE}) and modify loadNextPage() to fetch only the next slice from the server instead of slicing the local allExercises array. Keep the Intersection Observer implementation identical.

How do I prevent duplicate requests while loading?

If using a real API, add a boolean isLoading flag that sets to true at the start of loadNextPage() and resets to false in a .finally() block. Check this flag at the beginning of the function and return early if a request is already in progress. With the static JSON approach shown above, requests are synchronous after the initial fetch, so duplicates are not a concern.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →