Create infinite scroll in JavaScript, in one HTML file

Infinite scroll is a list, an empty marker after it, and an IntersectionObserver that adds more items when the marker comes into view. Try the smallest version, then copy a finished feed.

Infinite scroll needs three parts: a list, an empty marker after it called a sentinel, and an IntersectionObserver that watches the sentinel. When the sentinel comes into view, the callback adds the next batch of items.

No library is needed, and the whole thing fits in one HTML file. Scroll the list below to the bottom and it keeps growing.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Infinite scroll, smallest version</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { height: 36px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between;
         font-size: 13px; background: #fff; border-bottom: 1px solid #e1e4ea; }
  #feed { height: 300px; overflow-y: auto; padding: 10px 14px; }  /* the box that scrolls */
  ul { list-style: none; margin: 0; padding: 0; }
  li { padding: 12px 14px; margin-bottom: 8px; background: #fff; border-radius: 10px;
       box-shadow: 0 1px 3px rgba(0, 0, 0, .1); }
  #sentinel { height: 1px; }  /* empty marker after the list */
</style>
</head>
<body>
<div class="bar"><b>Scroll the list</b><span id="status">0 items</span></div>
<div id="feed">
  <ul id="list"></ul>
  <div id="sentinel"></div>
</div>

<script>
  const feed = document.getElementById('feed');
  const list = document.getElementById('list');
  const sentinel = document.getElementById('sentinel');
  let count = 0;

  function addItems(n) {
    for (let i = 0; i < n; i++) {
      const li = document.createElement('li');
      li.textContent = 'Item ' + (++count);
      list.append(li);
    }
    document.getElementById('status').textContent = count + ' items';
  }

  const observer = new IntersectionObserver((entries) => {
    if (!entries[0].isIntersecting) return;
    addItems(10);
    // Observe again so the browser re-checks: a short list can leave the sentinel in view
    observer.unobserve(sentinel);
    observer.observe(sentinel);
  }, { root: feed, rootMargin: '0px 0px 200px 0px' });  // start 200px before the end

  observer.observe(sentinel);
</script>
</body>
</html>
A short script. The list grows each time the sentinel comes within 200px of the bottom.

The items here are made up by a small function. A real page would ask a server for them, and the finished example further down shows where that call goes.

How it works, step by step

Three moments: the sentinel is far away, then it enters the zone and the callback runs, then new items push it back out.
Three moments: the sentinel is far away, then it enters the zone and the callback runs, then new items push it back out.
  1. Make a box that scrolls. Give the list container a fixed height and overflow-y: auto. This box becomes the observer's root.
  2. Add a sentinel. An empty <div> right after the list. It is the thing the observer watches.
  3. Create the observer. Call new IntersectionObserver() with a callback and the options root and rootMargin. The rootMargin string works like a CSS margin and grows the watched area.
  4. Add items when it intersects. In the callback, check entries[0].isIntersecting and append the next batch above the sentinel.
  5. Observe the sentinel again. Call unobserve() and then observe() on it after each batch, so the browser reports its position once more.
  6. Stop at the end. When nothing is left to load, call observer.disconnect().

Step 5 is the one people miss. The callback runs when the sentinel's state changes, and a short batch can leave the sentinel inside the zone. Nothing then crosses, so the list stops growing after one batch.

observe() registers the sentinel as if it were new, so the browser reports it again. That second report is what fills a tall screen.

To scroll the whole page instead of a box, leave out root. The observer then watches the viewport. Inside an embedded frame the margin can be ignored, which the IntersectionObserver guide explains. A scrolling box as root avoids that.

Scroll event or IntersectionObserver

The other common approach listens to scroll on the box and works out how far from the end it is:

const left = box.scrollHeight - box.scrollTop - box.clientHeight;
if (left < 200) addItems();  // 200px or less still hidden below

This works, but it runs for every scroll event. Press the button and compare the two counters.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll event vs IntersectionObserver</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 10px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; font-size: 13px; }
  button { font: inherit; padding: 6px 12px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  button:disabled { background: #9aa3b2; cursor: default; }
  .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  h3 { margin: 0 0 4px; font-size: 13px; }
  .count { font-size: 12px; color: #4b5563; margin-bottom: 6px; min-height: 32px; }
  .count b { color: #1d2330; font-size: 15px; }
  .feed { height: 270px; overflow-y: auto; padding: 6px; background: #fff; border-radius: 10px; border: 1px solid #e1e4ea; }
  .feed div.item { padding: 8px 10px; margin-bottom: 6px; background: #f4f5f7; border-radius: 6px; font-size: 13px; }
  .sentinel { height: 1px; }
</style>
</head>
<body>
<div class="top"><span>Same list, two ways to know "near the end".</span><button id="go">Scroll both</button></div>
<div class="cols">
  <div>
    <h3>scroll event</h3>
    <div class="count">handler ran <b id="a-runs">0</b> times<br><span id="a-items">0</span> items</div>
    <div class="feed" id="a"></div>
  </div>
  <div>
    <h3>IntersectionObserver</h3>
    <div class="count">callback ran <b id="b-runs">0</b> times<br><span id="b-items">0</span> items</div>
    <div class="feed" id="b"><div class="sentinel" id="b-sentinel"></div></div>
  </div>
</div>

<script>
  const a = document.getElementById('a');
  const b = document.getElementById('b');
  const sentinel = document.getElementById('b-sentinel');
  let aRuns = 0, bRuns = 0, aCount = 0, bCount = 0;

  function makeItem(n) {
    const d = document.createElement('div');
    d.className = 'item';
    d.textContent = 'Item ' + n;
    return d;
  }

  // Left: a scroll listener has to measure on every event
  function checkA() {
    aRuns++;
    const left = a.scrollHeight - a.scrollTop - a.clientHeight;  // pixels still below the view
    if (left < 200) {
      for (let i = 0; i < 10; i++) a.append(makeItem(++aCount));
    }
    document.getElementById('a-runs').textContent = aRuns;
    document.getElementById('a-items').textContent = aCount;
  }
  a.addEventListener('scroll', checkA);
  checkA();  // fill the first screen

  // Right: the browser calls back only when the sentinel crosses the line
  const observer = new IntersectionObserver((entries) => {
    bRuns++;
    if (entries[0].isIntersecting) {
      const items = [];
      for (let i = 0; i < 10; i++) items.push(makeItem(++bCount));
      sentinel.before(...items);  // add the batch above the sentinel
      observer.unobserve(sentinel);
      observer.observe(sentinel);
    }
    document.getElementById('b-runs').textContent = bRuns;
    document.getElementById('b-items').textContent = bCount;
  }, { root: b, rootMargin: '0px 0px 200px 0px' });
  observer.observe(sentinel);

  // The button scrolls both lists the same distance, a few pixels per frame
  document.getElementById('go').addEventListener('click', (e) => {
    const btn = e.currentTarget;
    btn.disabled = true;
    let moved = 0;
    (function step() {
      a.scrollTop += 12;
      b.scrollTop += 12;
      moved += 12;
      if (moved < 1500) requestAnimationFrame(step);
      else btn.disabled = false;
    })();
  });
</script>
</body>
</html>
Both lists load ten items at a time. The button scrolls both the same distance, and each side counts how often its code ran.
A scroll handler runs on every event. An observer callback runs when the sentinel crosses the line. This is an illustration, not a measurement.
A scroll handler runs on every event. An observer callback runs when the sentinel crosses the line. This is an illustration, not a measurement.
Scroll event IntersectionObserver
Runs On scroll events, which can fire at a high rate When the sentinel crosses the zone edge
You measure scrollHeight, scrollTop, clientHeight Nothing, read isIntersecting
First screen You call the check once yourself The first report arrives after observe()
Stop removeEventListener disconnect()

If you choose the scroll event, do not compare with ===. scrollTop can have a decimal part while scrollHeight and clientHeight are rounded, so test for "close enough" with a small tolerance, as MDN's own example does. See addEventListener for the listener basics.

Load before the reader reaches the end

With the default margin, loading starts only when the sentinel touches the edge of the box. The reader arrives first and waits.

Left: no margin, so the reader waits at a loading row. Right: a bottom margin of 200px, so items arrive before the edge.
Left: no margin, so the reader waits at a loading row. Right: a bottom margin of 200px, so items arrive before the edge.

The fix is one option: rootMargin: '0px 0px 200px 0px'. The four values are top, right, bottom and left, in px or %. A positive bottom value reaches below the box, so the callback runs while the sentinel is still out of sight.

A larger value starts loading earlier. A value of 0px starts at the edge.

A finished feed: loading, retry and an end

The smallest version assumes loading is instant and never fails. A real feed needs a few more lines.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Infinite feed with loading, retry and an end</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  header { height: 48px; padding: 0 14px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
           background: #fff; border-bottom: 1px solid #e1e4ea; font-size: 13px; }
  header .grow { flex: 1; }
  header button { font: inherit; padding: 4px 10px; border: 1px solid #cfd5df; border-radius: 8px; background: #fff; cursor: pointer; }
  #feed { height: 432px; overflow-y: auto; padding: 10px 14px; }
  ul { list-style: none; margin: 0; padding: 0; }
  li { display: flex; gap: 12px; align-items: center; padding: 10px; margin-bottom: 8px;
       background: #fff; border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, .1); }
  .pic { width: 48px; height: 48px; flex: none; border-radius: 8px; }
  li b { display: block; font-size: 14px; }
  li span { font-size: 12px; color: #6b7280; }
  #foot { text-align: center; padding: 10px 0 16px; font-size: 13px; color: #4b5563; }
  #foot button { font: inherit; margin-top: 6px; padding: 7px 16px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  #foot.error #msg { color: #b45309; }
  .spin { display: inline-block; width: 12px; height: 12px; margin-right: 6px; vertical-align: -2px; border: 2px solid #cfd5df;
          border-top-color: #2563eb; border-radius: 50%; animation: r .8s linear infinite; }
  @keyframes r { to { transform: rotate(360deg); } }
  @media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
</style>
</head>
<body>
<header>
  <b>Photos</b>
  <span id="status">0 of 60</span>
  <span class="grow"></span>
  <label><input type="checkbox" id="fail"> Next load fails</label>
  <button id="top" type="button">Top</button>
</header>
<div id="feed">
  <ul id="list"></ul>
  <div id="foot"><div id="msg"></div><button id="more" type="button">Load more</button></div>
</div>

<script>
  const TOTAL = 60, PAGE = 10;
  const feed = document.getElementById('feed');
  const list = document.getElementById('list');
  const foot = document.getElementById('foot');
  const msg = document.getElementById('msg');
  const more = document.getElementById('more');
  let loaded = 0;
  let busy = false;  // true while a request is pending

  // Stand-in for fetch(): answers after 500 ms with the next page of numbers
  function getPage(from) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        const failBox = document.getElementById('fail');
        if (failBox.checked) { failBox.checked = false; reject(new Error('network')); return; }
        resolve(Array.from({ length: Math.min(PAGE, TOTAL - from) }, (_, i) => from + i + 1));
      }, 500);
    });
  }

  function makeItem(n) {
    const li = document.createElement('li');
    li.innerHTML = '<div class="pic"></div><div><b></b><span></span></div>';
    li.querySelector('.pic').style.background =
      'linear-gradient(135deg, hsl(' + (n * 47 % 360) + ' 70% 62%), hsl(' + (n * 47 % 360 + 40) + ' 70% 45%))';
    li.querySelector('b').textContent = 'Photo ' + n;
    li.querySelector('span').textContent = 'Page ' + Math.ceil(n / PAGE);
    return li;
  }

  async function loadMore() {
    if (busy || loaded >= TOTAL) return;  // one request at a time
    busy = true;
    foot.className = '';
    more.hidden = true;
    msg.innerHTML = '<span class="spin"></span>Loading';
    try {
      const nums = await getPage(loaded);
      list.append(...nums.map(makeItem));
      loaded += nums.length;
    } catch (err) {
      foot.className = 'error';
      msg.textContent = 'Could not load the next page.';
      more.textContent = 'Retry';
      more.hidden = false;
      busy = false;
      return;  // do not re-observe: wait for a click, so a failure cannot loop
    }
    busy = false;
    document.getElementById('status').textContent = loaded + ' of ' + TOTAL;
    if (loaded >= TOTAL) {
      observer.disconnect();  // nothing left to load
      msg.textContent = 'You reached the end.';
      return;
    }
    msg.textContent = '';
    more.textContent = 'Load more';
    more.hidden = false;
    observer.unobserve(foot);
    observer.observe(foot);  // check again in case the page did not fill the screen
  }

  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) loadMore();
  }, { root: feed, rootMargin: '0px 0px 200px 0px' });
  observer.observe(foot);

  more.addEventListener('click', loadMore);  // manual route, same function
  document.getElementById('top').addEventListener('click', () => feed.scrollTo({ top: 0 }));
</script>
</body>
</html>
60 photos in pages of ten. Tick the box to make the next load fail, then use Retry. At the end the observer stops.
  • One request at a time. A busy flag at the top of loadMore() ignores calls while a request is pending.
  • Visible state. A spinner row while loading, and an error message with a Retry button.
  • No automatic retry loop. After a failure the function returns before observing again, so the next attempt waits for a click.
  • A manual route. The Load more button calls the same function.
  • An end. On the last page the code calls disconnect() and shows a closing message.

In the example, getPage() waits 500 ms and returns numbers. To use a server, replace its body:

async function getPage(from) {
  const res = await fetch('/api/items?from=' + from);
  if (!res.ok) throw new Error('HTTP ' + res.status);  // 404 does not reject by itself
  return res.json();
}

fetch() fulfills with a response even for a 404 or 500, so check res.ok and throw yourself. In NOS, fetch to other sites is blocked, so a shared page keeps its data inside the page, as these examples do.

Do you need a library?

For a growing list, one observer and one function are the whole feature, so a library is optional. If you want one anyway, NOS loads scripts from cdnjs.cloudflare.com, cdn.jsdelivr.net and unpkg.com. Pin the version in the URL.

Every appended item stays in the page. For a feed that could grow very long, content-visibility: auto lets the browser skip rendering work for items that are off screen. CSS content-visibility shows how to set it with a size hint.

Adding items at the top of a list, as a chat does with older messages, is a different job. CSS overflow-anchor covers it.

When it does not work

What you see Cause Fix
It loads once, then stops The sentinel never left the zone, so nothing crossed unobserve() then observe() after each batch
The same page loads twice A scroll handler runs again while a request is pending A busy flag, or an observer
The reader sees a gap at the end rootMargin is 0px Add a bottom margin such as 200px
A failed request repeats forever The error path observes again Return early and offer Retry
Footer links are out of reach The list grows each time the bottom is reached Put links outside the scrolling box, or end the list
The end check never fires scrollTop has a decimal part Compare with a tolerance, not ===
The list gets very long Nothing ever removes old items content-visibility: auto, or a cap
The preload distance has no effect in an embedded page rootMargin can be ignored when root is left out Use the scrolling box as root

Infinite scroll is something you feel by scrolling, and a screenshot only shows a still list. An .html attachment may open as plain code, or not at all, on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so anyone with the link can open it without an account and scroll the list themselves. If you change the code later, the same link shows the new version.

Single-file HTML apps has more ideas of this size.

Questions people ask

How do I create infinite scroll in JavaScript without a library?

Put an empty element, called a sentinel, after your list. Watch it with an IntersectionObserver. When the callback reports entry.isIntersecting, append the next batch of items above the sentinel. No other code is needed for a basic list.

Should I use the scroll event or IntersectionObserver?

Both work. A scroll listener runs on scroll events, which MDN notes can fire at a high rate, and it has to compare scrollHeight, scrollTop and clientHeight each time. An observer runs when the sentinel crosses the edge of its zone, so it needs no measuring.

Why does my infinite scroll load once and then stop?

The first batch was too short to push the sentinel out of the observer's zone. The sentinel never leaves, so nothing crosses and the callback does not run again. Call unobserve() and then observe() on the sentinel after each batch to get a fresh report.

How do I stop infinite scroll at the end of the data?

When the last page has loaded, call observer.disconnect() and show an end message. The observer then stops watching the sentinel, so scrolling does not trigger another load.

Does infinite scroll work on a shared NOS page?

Yes. The page renders as written and its scripts run, so the list keeps growing as people scroll. Fetch to other sites is blocked there, so the items must come from inside the page, as in the examples here.

Keep reading