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
- Paste the snippet just before
</head>. - Add
data-infinite-load-into your list. A loader appears after the last item. - When the loader comes into view the list fires an
infinite-load-in:moreevent. Fetch the next page and append the items to the list: they glide in by themselves. Setdata-doneon the list when there’s nothing left.
Options
Add any of these attributes to the same element.
data-duration | How long each item takes to glide in. Default 650 ms. |
data-stagger | Gap between items added together. Default 90 ms. |
data-distance | How far they rise. Default 28 px. |
data-loader="false" | No loader or event: just animate items whenever 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: the most items kept, dropping old ones from the top. Default 40. |
<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><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>