← Scroll

Infinite Load-in

When you reach the end of a feed, a loader spins and the next items glide up into place one after another, so the list grows smoothly instead of popping. The demo loads more for ever; scroll it yourself to take over.

  • Morning swimCold water, warm coffee after.
  • New recipeLemon and thyme roast chicken.
  • Trip notesThree days in the Lake District.
  • Studio tourWhere the magic (and mess) happens.
  • Garden diaryThe tomatoes finally turned red.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-infinite-load-in to your list. A loader appears after the last item.
  3. When the loader comes into view the list fires an infinite-load-in:more event. Fetch the next page and append the items to the list: they glide in by themselves. Set data-done on the list when there’s nothing left.

Options

Add any of these attributes to the same element.

data-durationHow long each item takes to glide in. Default 650 ms.
data-staggerGap between items added together. Default 90 ms.
data-distanceHow far they rise. Default 28 px.
data-loader="false"No loader or event: just animate items whenever they’re added.
data-demoFake the loading by copying the first items, for trying it out.
data-autoplayWith data-demo: scroll the list’s box down by itself to keep loading, until someone scrolls it.
data-maxWith data-demo: the most items kept, dropping old ones from the top. Default 40.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Infinite Load-in: when you reach the end of a list, a loader appears and
    // the next items glide up into place one after another, so a feed grows
    // smoothly instead of popping.
    // Usage: <ul data-infinite-load-in>…items…</ul>
    // When the loader at the bottom comes into view, the list fires an
    // "infinite-load-in:more" event; append the new items to the list in your
    // handler and they glide in. Set data-done on the list when there's
    // nothing more to load.
    // Optional: data-duration (ms each item takes, default 650), data-stagger
    // (ms between items, default 90), data-distance (px they rise, default
    // 28), data-loader="false" (no loader: just animate items as they're
    // added), data-demo (fake the loading by copying the first items, for
    // trying it out), data-autoplay (with data-demo: scroll the list's box down
    // by itself to keep loading, until someone scrolls it), data-max (with
    // data-demo: most items kept, dropping old ones from the top, default 40).
    var DEFAULTS = { duration: 650, stagger: 90, distance: 28, max: 40 };

    var css =
      '.ild-loader{display:flex;align-items:center;justify-content:center;gap:10px;padding:14px 0;list-style:none;color:inherit;font-size:13px;opacity:.75}' +
      '.ild-loader i{width:16px;height:16px;border:2.5px solid currentColor;border-right-color:transparent;border-radius:50%;animation:ild-spin .7s linear infinite}' +
      '.ild-loader.ild-idle{visibility:hidden}' +
      '.ild-loader.ild-end{display:none}' +
      '@keyframes ild-spin{to{transform:rotate(360deg)}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if (o === 'auto' || o === 'scroll') return p;
      }
      return null;
    }

    function on(v) { return v !== undefined && v !== 'false'; }

    function setup(el) {
      var d = el.dataset;
      var duration = num(d.duration, DEFAULTS.duration);
      var stagger = num(d.stagger, DEFAULTS.stagger);
      var distance = num(d.distance, DEFAULTS.distance);
      var max = num(d.max, DEFAULTS.max);
      var demo = on(d.demo);
      var autoplay = demo && on(d.autoplay) && !reduced();
      var box = scroller(el);
      var templates = Array.prototype.slice.call(el.children);
      var copies = 0, loading = false, visible = false, queue = 0, queueAt = 0;

      // The loader sits after the last item (an <li> in a list).
      var loader = null;
      if (d.loader !== 'false') {
        loader = document.createElement(/^(UL|OL)$/.test(el.tagName) ? 'li' : 'div');
        loader.className = 'ild-loader ild-idle';
        loader.setAttribute('role', 'status');
        loader.innerHTML = '<i aria-hidden="true"></i><span>Loading more</span>';
        el.appendChild(loader);
      }

      function glide(item) {
        if (reduced() || !window.anime) return;
        // Items added together glide in one after another.
        var now = performance.now();
        if (now - queueAt > 60) queue = 0;
        queueAt = now;
        anime.animate(item, {
          opacity: [0, 1],
          y: [distance, 0],
          scale: [0.96, 1],
          duration: duration,
          delay: queue++ * stagger,
          ease: 'out(4)',
          onComplete: function () { item.style.transform = ''; item.style.opacity = ''; },
        });
        item.style.opacity = '0';
      }

      function finish() {
        loading = false;
        if (!loader) return;
        loader.classList.add('ild-idle');
        if (on(el.dataset.done)) loader.classList.add('ild-end');
        else if (loader.parentNode === el) el.appendChild(loader);   // keep it last
        check();
      }

      // Watch for new items: animate them in and settle the loader.
      new MutationObserver(function (records) {
        var added = false;
        records.forEach(function (r) {
          Array.prototype.forEach.call(r.addedNodes, function (n) {
            if (n.nodeType !== 1 || n === loader) return;
            added = true;
            glide(n);
          });
        });
        if (added && loading) setTimeout(finish, 30);
      }).observe(el, { childList: true });

      function check() {
        if (visible && !loading && loader && !on(el.dataset.done)) more();
      }

      function more() {
        loading = true;
        loader.classList.remove('ild-idle');
        el.dispatchEvent(new CustomEvent('infinite-load-in:more', { bubbles: true }));
        if (demo) setTimeout(fake, reduced() ? 300 : 900);
        else setTimeout(function () { if (loading) finish(); }, 10000);   // give up waiting after 10 s
      }

      // Demo loading: copies of the first items, dropping old ones from the top.
      function fake() {
        var frag = document.createDocumentFragment();
        for (var i = 0; i < 3; i++) {
          var c = templates[copies++ % templates.length].cloneNode(true);
          c.removeAttribute('id');
          frag.appendChild(c);
        }
        el.insertBefore(frag, loader);
        var items = Array.prototype.filter.call(el.children, function (n) { return n !== loader; });
        var extra = items.length - max;
        if (extra > 0) {
          var before = items[extra].offsetTop;
          var top0 = items[0].offsetTop;
          for (var k = 0; k < extra; k++) el.removeChild(items[k]);
          if (box) box.scrollTop -= before - top0;
        }
        if (autoplay) setTimeout(glideDown, 250);
      }

      // Autoplay: smoothly scroll the box so the new items come into view.
      var userTouched = false, boxOnScreen = false;
      function glideDown() {
        if (userTouched || !box || !boxOnScreen) return;
        box.scrollTo({ top: box.scrollHeight - box.clientHeight, behavior: 'smooth' });
      }
      if (autoplay && box) {
        box.style.overflowAnchor = 'none';   // we keep the place ourselves when old items go
        ['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) {
          box.addEventListener(t, function () { userTouched = true; }, { passive: true });
        });
      }

      if (loader) {
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          check();
        }, { root: box, rootMargin: '0px 0px 40px 0px' }).observe(loader);
      }
      // Start the demo by gliding down to the loader once the box is on screen.
      if (autoplay && box) {
        new IntersectionObserver(function (entries) {
          boxOnScreen = entries[0].isIntersecting;
          if (boxOnScreen && !loading) setTimeout(glideDown, 700);
        }).observe(box);
      }
    }

    function init() {
      document.querySelectorAll('[data-infinite-load-in]').forEach(function (el) {
        if (el._ild) return;
        el._ild = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<ul id="feed" data-infinite-load-in>
  <li>…</li>
</ul>

<script>
  feed.addEventListener('infinite-load-in:more', async () => {
    const items = await fetchNextPage();      // your own loading
    if (!items.length) feed.dataset.done = '';
    items.forEach((html) => feed.insertAdjacentHTML('beforeend', html));
  });
</script>