12 CSS Masonry Layouts11 / 12
CSS Masonry Infinite Scroll Dynamic Loading
Infinite scroll done the way the layout gods intended: the wall is demo 03's append-stable grid-span engine (columns would reshuffle every card on each load — the classic library bug, explained), a sentinel row of shimmering skeleton cards trips an IntersectionObserver 600px early, batches append with zero existing-card movement, an aria-live region narrates progress, and the stream ends honestly with 'all caught up' instead of a spinner that lies.
Published
The code
<section class="msn-11" aria-label="CSS masonry infinite scroll demo">
<div class="msn-11__stage" tabindex="0">
<header class="msn-11__head">
<div><h2>Discover</h2><p>Endless-ish · stops honestly at 40</p></div>
<span class="msn-11__status" role="status" aria-live="polite">Loaded 8 of 40</span>
</header>
<div class="msn-11__grid">
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1469474968028-56623f02e42e?q=80&w=700&auto=format&fit=crop');--a:oklch(0.75 0.1 130);--b:oklch(0.45 0.11 150)"><div class="msn-11__cap"><strong>Shot 1</strong><span>3.2k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1482938289607-e9573fc25ebb?q=80&w=700&auto=format&fit=crop');--a:oklch(0.72 0.09 230);--b:oklch(0.45 0.1 250)"><div class="msn-11__cap"><strong>Shot 2</strong><span>2.8k likes</span></div></article>
<article class="msn-11__card msn-11__card--w" style="--img:url('https://images.unsplash.com/photo-1501594907352-04cda38ebc29?q=80&w=700&auto=format&fit=crop');--a:oklch(0.7 0.1 40);--b:oklch(0.45 0.13 20)"><div class="msn-11__cap"><strong>Shot 3</strong><span>2.1k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1519125323398-675f0ddb6308?q=80&w=700&auto=format&fit=crop');--a:oklch(0.8 0.07 120);--b:oklch(0.55 0.09 100)"><div class="msn-11__cap"><strong>Shot 4</strong><span>1.9k likes</span></div></article>
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?q=80&w=700&auto=format&fit=crop');--a:oklch(0.7 0.1 140);--b:oklch(0.42 0.1 160)"><div class="msn-11__cap"><strong>Shot 5</strong><span>1.7k likes</span></div></article>
<article class="msn-11__card msn-11__card--w" style="--img:url('https://images.unsplash.com/photo-1472214103451-9374bd1c798e?q=80&w=700&auto=format&fit=crop');--a:oklch(0.8 0.09 90);--b:oklch(0.55 0.12 70)"><div class="msn-11__cap"><strong>Shot 6</strong><span>1.4k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?q=80&w=700&auto=format&fit=crop');--a:oklch(0.74 0.09 220);--b:oklch(0.48 0.1 240)"><div class="msn-11__cap"><strong>Shot 7</strong><span>1.2k likes</span></div></article>
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1433086966358-54859d0ed716?q=80&w=700&auto=format&fit=crop');--a:oklch(0.78 0.08 160);--b:oklch(0.5 0.09 180)"><div class="msn-11__cap"><strong>Shot 8</strong><span>980 likes</span></div></article>
<div class="msn-11__sentinel" aria-hidden="true"><i></i><i></i><i></i></div>
</div>
<footer class="msn-11__foot"><p>You can always reach me — that's the point.</p><a href="#top">Back to top</a></footer>
</div>
</section><section class="msn-11" aria-label="CSS masonry infinite scroll demo">
<div class="msn-11__stage" tabindex="0">
<header class="msn-11__head">
<div><h2>Discover</h2><p>Endless-ish · stops honestly at 40</p></div>
<span class="msn-11__status" role="status" aria-live="polite">Loaded 8 of 40</span>
</header>
<div class="msn-11__grid">
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1469474968028-56623f02e42e?q=80&w=700&auto=format&fit=crop');--a:oklch(0.75 0.1 130);--b:oklch(0.45 0.11 150)"><div class="msn-11__cap"><strong>Shot 1</strong><span>3.2k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1482938289607-e9573fc25ebb?q=80&w=700&auto=format&fit=crop');--a:oklch(0.72 0.09 230);--b:oklch(0.45 0.1 250)"><div class="msn-11__cap"><strong>Shot 2</strong><span>2.8k likes</span></div></article>
<article class="msn-11__card msn-11__card--w" style="--img:url('https://images.unsplash.com/photo-1501594907352-04cda38ebc29?q=80&w=700&auto=format&fit=crop');--a:oklch(0.7 0.1 40);--b:oklch(0.45 0.13 20)"><div class="msn-11__cap"><strong>Shot 3</strong><span>2.1k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1519125323398-675f0ddb6308?q=80&w=700&auto=format&fit=crop');--a:oklch(0.8 0.07 120);--b:oklch(0.55 0.09 100)"><div class="msn-11__cap"><strong>Shot 4</strong><span>1.9k likes</span></div></article>
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?q=80&w=700&auto=format&fit=crop');--a:oklch(0.7 0.1 140);--b:oklch(0.42 0.1 160)"><div class="msn-11__cap"><strong>Shot 5</strong><span>1.7k likes</span></div></article>
<article class="msn-11__card msn-11__card--w" style="--img:url('https://images.unsplash.com/photo-1472214103451-9374bd1c798e?q=80&w=700&auto=format&fit=crop');--a:oklch(0.8 0.09 90);--b:oklch(0.55 0.12 70)"><div class="msn-11__cap"><strong>Shot 6</strong><span>1.4k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?q=80&w=700&auto=format&fit=crop');--a:oklch(0.74 0.09 220);--b:oklch(0.48 0.1 240)"><div class="msn-11__cap"><strong>Shot 7</strong><span>1.2k likes</span></div></article>
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1433086966358-54859d0ed716?q=80&w=700&auto=format&fit=crop');--a:oklch(0.78 0.08 160);--b:oklch(0.5 0.09 180)"><div class="msn-11__cap"><strong>Shot 8</strong><span>980 likes</span></div></article>
<div class="msn-11__sentinel" aria-hidden="true"><i></i><i></i><i></i></div>
</div>
<footer class="msn-11__foot"><p>You can always reach me — that's the point.</p><a href="#top">Back to top</a></footer>
</div>
</section>.msn-11,
.msn-11 *,
.msn-11 *::before,
.msn-11 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.msn-11 {
--bg: oklch(0.98 0.004 250);
--ink: oklch(0.23 0.015 260);
--mut: oklch(0.55 0.015 260);
--line: oklch(0.9 0.008 250);
--acc: oklch(0.6 0.17 300);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
container-type: inline-size;
display: block;
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: var(--bg);
}
.msn-11__stage {
width: 100%;
container: msn11/inline-size;
height: 100vh;
height: 100svh;
overflow-y: auto;
background: var(--bg);
}
.msn-11__head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 15px clamp(16px,3cqi,26px);
background: color-mix(in oklch,var(--bg) 84%,transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.msn-11__head h2 {
font-size: clamp(17px,2.4cqi,22px);
letter-spacing: -.02em;
}
.msn-11__head p {
font-size: 11.5px;
color: var(--mut);
margin-top: 2px;
}
.msn-11__status {
font-size: 11.5px;
font-weight: 700;
color: var(--acc);
border: 1px solid color-mix(in oklch,var(--acc) 35%,var(--line));
background: color-mix(in oklch,var(--acc) 8%,transparent);
border-radius: 99px;
padding: 6px 12px;
font-variant-numeric: tabular-nums;
}
.msn-11__grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill,minmax(min(190px,100%),1fr));
grid-auto-rows: 10px;
padding: clamp(14px,3cqi,26px);
}
.msn-11__card {
position: relative;
border-radius: 13px;
overflow: clip;
background: linear-gradient(150deg,var(--a),var(--b));
}
.msn-11__card::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--img,none);
background-size: cover;
background-position: center;
transition: scale .4s cubic-bezier(.2,.7,.2,1);
}
.msn-11__card:hover::before {
scale: 1.04;
}
.msn-11__card::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg,transparent 55%,oklch(0.2 0.02 260/.6));
}
.msn-11__card--t {
grid-row: span 24;
}
.msn-11__card--s {
grid-row: span 15;
}
.msn-11__card--w {
grid-row: span 10;
}
.msn-11__cap {
position: absolute;
left: 11px;
right: 11px;
bottom: 9px;
z-index: 1;
color: oklch(0.99 0 0);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.msn-11__cap strong {
font-size: 12.5px;
letter-spacing: -.01em;
}
.msn-11__cap span {
font-size: 10.5px;
opacity: .85;
}
.msn-11__sentinel {
grid-column: 1/-1;
display: grid;
grid-template-columns: repeat(auto-fill,minmax(min(190px,100%),1fr));
gap: 10px;
grid-row: span 12;
}
.msn-11__sentinel i {
border-radius: 13px;
background: linear-gradient(100deg,oklch(0.93 0.006 250) 40%,oklch(0.965 0.004 250) 50%,oklch(0.93 0.006 250) 60%) 0 0/240% 100%;
animation: msn11-shimmer 1.3s linear infinite;
}
.msn-11__sentinel i:nth-child(3) {
display: none;
}
@keyframes msn11-shimmer {
to {
background-position: -140% 0;
}
}
.msn-11__sentinel.is-done {
display: flex;
align-items: center;
justify-content: center;
grid-row: span 5;
}
.msn-11__sentinel.is-done::after {
content: "You're all caught up — 40 of 40";
font-size: 13px;
font-weight: 700;
color: var(--mut);
}
.msn-11__sentinel.is-done i {
display: none;
}
.msn-11__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px clamp(16px,3cqi,26px) 26px;
border-top: 1px solid var(--line);
}
.msn-11__foot p {
font-size: 12px;
color: var(--mut);
}
.msn-11__foot a {
font-size: 12.5px;
font-weight: 700;
color: var(--acc);
text-decoration: none;
min-height: 44px;
display: inline-flex;
align-items: center;
}
.msn-11__foot a:hover,
.msn-11__foot a:focus-visible {
text-decoration: underline;
text-underline-offset: 3px;
}
.msn-11__foot a:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 2px;
border-radius: 4px;
}
@container msn11 (width < 460px) {
.msn-11__grid {
grid-template-columns: repeat(2,1fr);
gap: 8px;
}
.msn-11__sentinel {
grid-template-columns: repeat(2,1fr);
}
.msn-11__sentinel i:nth-child(3) {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.msn-11 * {
transition-duration: .01ms !important;
animation: none !important;
}
}.msn-11,
.msn-11 *,
.msn-11 *::before,
.msn-11 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.msn-11 {
--bg: oklch(0.98 0.004 250);
--ink: oklch(0.23 0.015 260);
--mut: oklch(0.55 0.015 260);
--line: oklch(0.9 0.008 250);
--acc: oklch(0.6 0.17 300);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
container-type: inline-size;
display: block;
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: var(--bg);
}
.msn-11__stage {
width: 100%;
container: msn11/inline-size;
height: 100vh;
height: 100svh;
overflow-y: auto;
background: var(--bg);
}
.msn-11__head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 15px clamp(16px,3cqi,26px);
background: color-mix(in oklch,var(--bg) 84%,transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.msn-11__head h2 {
font-size: clamp(17px,2.4cqi,22px);
letter-spacing: -.02em;
}
.msn-11__head p {
font-size: 11.5px;
color: var(--mut);
margin-top: 2px;
}
.msn-11__status {
font-size: 11.5px;
font-weight: 700;
color: var(--acc);
border: 1px solid color-mix(in oklch,var(--acc) 35%,var(--line));
background: color-mix(in oklch,var(--acc) 8%,transparent);
border-radius: 99px;
padding: 6px 12px;
font-variant-numeric: tabular-nums;
}
.msn-11__grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill,minmax(min(190px,100%),1fr));
grid-auto-rows: 10px;
padding: clamp(14px,3cqi,26px);
}
.msn-11__card {
position: relative;
border-radius: 13px;
overflow: clip;
background: linear-gradient(150deg,var(--a),var(--b));
}
.msn-11__card::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--img,none);
background-size: cover;
background-position: center;
transition: scale .4s cubic-bezier(.2,.7,.2,1);
}
.msn-11__card:hover::before {
scale: 1.04;
}
.msn-11__card::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg,transparent 55%,oklch(0.2 0.02 260/.6));
}
.msn-11__card--t {
grid-row: span 24;
}
.msn-11__card--s {
grid-row: span 15;
}
.msn-11__card--w {
grid-row: span 10;
}
.msn-11__cap {
position: absolute;
left: 11px;
right: 11px;
bottom: 9px;
z-index: 1;
color: oklch(0.99 0 0);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.msn-11__cap strong {
font-size: 12.5px;
letter-spacing: -.01em;
}
.msn-11__cap span {
font-size: 10.5px;
opacity: .85;
}
.msn-11__sentinel {
grid-column: 1/-1;
display: grid;
grid-template-columns: repeat(auto-fill,minmax(min(190px,100%),1fr));
gap: 10px;
grid-row: span 12;
}
.msn-11__sentinel i {
border-radius: 13px;
background: linear-gradient(100deg,oklch(0.93 0.006 250) 40%,oklch(0.965 0.004 250) 50%,oklch(0.93 0.006 250) 60%) 0 0/240% 100%;
animation: msn11-shimmer 1.3s linear infinite;
}
.msn-11__sentinel i:nth-child(3) {
display: none;
}
@keyframes msn11-shimmer {
to {
background-position: -140% 0;
}
}
.msn-11__sentinel.is-done {
display: flex;
align-items: center;
justify-content: center;
grid-row: span 5;
}
.msn-11__sentinel.is-done::after {
content: "You're all caught up — 40 of 40";
font-size: 13px;
font-weight: 700;
color: var(--mut);
}
.msn-11__sentinel.is-done i {
display: none;
}
.msn-11__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px clamp(16px,3cqi,26px) 26px;
border-top: 1px solid var(--line);
}
.msn-11__foot p {
font-size: 12px;
color: var(--mut);
}
.msn-11__foot a {
font-size: 12.5px;
font-weight: 700;
color: var(--acc);
text-decoration: none;
min-height: 44px;
display: inline-flex;
align-items: center;
}
.msn-11__foot a:hover,
.msn-11__foot a:focus-visible {
text-decoration: underline;
text-underline-offset: 3px;
}
.msn-11__foot a:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 2px;
border-radius: 4px;
}
@container msn11 (width < 460px) {
.msn-11__grid {
grid-template-columns: repeat(2,1fr);
gap: 8px;
}
.msn-11__sentinel {
grid-template-columns: repeat(2,1fr);
}
.msn-11__sentinel i:nth-child(3) {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.msn-11 * {
transition-duration: .01ms !important;
animation: none !important;
}
}(function () {
document.querySelectorAll('.msn-11').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var scroller = root.querySelector('.msn-11__stage');
var grid = root.querySelector('.msn-11__grid');
var sent = root.querySelector('.msn-11__sentinel');
var status = root.querySelector('.msn-11__status');
var BATCH = 8, TOTAL = 40, loaded = 8, busy = false;
var kinds = ['t', 's', 'w', 's', 't', 'w', 's', 's'];
function addBatch() {
var frag = document.createDocumentFragment();
for (var i = 0; i < BATCH; i++) {
var n = loaded + i + 1, hue = (n * 47) % 360;
var el = document.createElement('article');
el.className = 'msn-11__card msn-11__card--' + kinds[(n + i) % kinds.length];
el.style.setProperty('--a', 'oklch(0.78 0.11 ' + hue + ')');
el.style.setProperty('--b', 'oklch(0.5 0.14 ' + ((hue + 45) % 360) + ')');
el.innerHTML = '<div class="msn-11__cap"><strong>Shot ' + n + '</strong><span>' + (2 + (n % 7)) + '.' + (n % 10) + 'k likes</span></div>';
frag.appendChild(el);
}
grid.insertBefore(frag, sent);
loaded += BATCH;
status.textContent = 'Loaded ' + loaded + ' of ' + TOTAL;
if (loaded >= TOTAL) { io.disconnect(); sent.classList.add('is-done'); }
}
// Watch BOTH the stage's own scroll (root: scroller) AND the outer
// viewport (root: null) so infinite scroll fires whether the user
// scrolls inside the fixed-height stage or the outer page/iframe.
// Necessary because when the initial 8-card wall doesn't overflow
// the stage, the outer scroll is the only one the user can trigger.
function onIntersect(entries) {
if (busy || loaded >= TOTAL) return;
var hit = entries.some(function (e) { return e.isIntersecting; });
if (!hit) return;
busy = true;
setTimeout(function () { /* swap this timeout for your fetch() */
addBatch();
busy = false;
// If the sentinel is STILL in view after the batch (large
// stage / small screens / initial full wall fits above the
// fold), keep loading in a chain without waiting for another
// scroll event — that would strand the user staring at the
// shimmer skeleton forever.
if (loaded < TOTAL) {
var r = sent.getBoundingClientRect();
var vh = window.innerHeight || document.documentElement.clientHeight;
if (r.top < vh + 600) setTimeout(function () { onIntersect([{ isIntersecting: true }]); }, 50);
}
}, 550);
}
var ioStage = new IntersectionObserver(onIntersect, { root: scroller, rootMargin: '600px 0px' });
var ioPage = new IntersectionObserver(onIntersect, { root: null, rootMargin: '600px 0px' });
var io = { disconnect: function () { ioStage.disconnect(); ioPage.disconnect(); } };
ioStage.observe(sent);
ioPage.observe(sent);
});
})();(function () {
document.querySelectorAll('.msn-11').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var scroller = root.querySelector('.msn-11__stage');
var grid = root.querySelector('.msn-11__grid');
var sent = root.querySelector('.msn-11__sentinel');
var status = root.querySelector('.msn-11__status');
var BATCH = 8, TOTAL = 40, loaded = 8, busy = false;
var kinds = ['t', 's', 'w', 's', 't', 'w', 's', 's'];
function addBatch() {
var frag = document.createDocumentFragment();
for (var i = 0; i < BATCH; i++) {
var n = loaded + i + 1, hue = (n * 47) % 360;
var el = document.createElement('article');
el.className = 'msn-11__card msn-11__card--' + kinds[(n + i) % kinds.length];
el.style.setProperty('--a', 'oklch(0.78 0.11 ' + hue + ')');
el.style.setProperty('--b', 'oklch(0.5 0.14 ' + ((hue + 45) % 360) + ')');
el.innerHTML = '<div class="msn-11__cap"><strong>Shot ' + n + '</strong><span>' + (2 + (n % 7)) + '.' + (n % 10) + 'k likes</span></div>';
frag.appendChild(el);
}
grid.insertBefore(frag, sent);
loaded += BATCH;
status.textContent = 'Loaded ' + loaded + ' of ' + TOTAL;
if (loaded >= TOTAL) { io.disconnect(); sent.classList.add('is-done'); }
}
// Watch BOTH the stage's own scroll (root: scroller) AND the outer
// viewport (root: null) so infinite scroll fires whether the user
// scrolls inside the fixed-height stage or the outer page/iframe.
// Necessary because when the initial 8-card wall doesn't overflow
// the stage, the outer scroll is the only one the user can trigger.
function onIntersect(entries) {
if (busy || loaded >= TOTAL) return;
var hit = entries.some(function (e) { return e.isIntersecting; });
if (!hit) return;
busy = true;
setTimeout(function () { /* swap this timeout for your fetch() */
addBatch();
busy = false;
// If the sentinel is STILL in view after the batch (large
// stage / small screens / initial full wall fits above the
// fold), keep loading in a chain without waiting for another
// scroll event — that would strand the user staring at the
// shimmer skeleton forever.
if (loaded < TOTAL) {
var r = sent.getBoundingClientRect();
var vh = window.innerHeight || document.documentElement.clientHeight;
if (r.top < vh + 600) setTimeout(function () { onIntersect([{ isIntersecting: true }]); }, 50);
}
}, 550);
}
var ioStage = new IntersectionObserver(onIntersect, { root: scroller, rootMargin: '600px 0px' });
var ioPage = new IntersectionObserver(onIntersect, { root: null, rootMargin: '600px 0px' });
var io = { disconnect: function () { ioStage.disconnect(); ioPage.disconnect(); } };
ioStage.observe(sent);
ioPage.observe(sent);
});
})();Here's a working CSS Masonry Layout from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Masonry Infinite Scroll Dynamic Loading
Source: https://codefronts.com/layouts/css-masonry-layouts/css-masonry-infinite-scroll-dynamic-loading/
Infinite scroll done the way the layout gods intended: the wall is demo 03's append-stable grid-span engine (columns would reshuffle every card on each load — the classic library bug, explained), a sentinel row of shimmering skeleton cards trips an IntersectionObserver 600px early, batches append with zero existing-card movement, an aria-live region narrates progress, and the stream ends honestly with 'all caught up' instead of a spinner that lies.
## HTML
```html
<section class="msn-11" aria-label="CSS masonry infinite scroll demo">
<div class="msn-11__stage" tabindex="0">
<header class="msn-11__head">
<div><h2>Discover</h2><p>Endless-ish · stops honestly at 40</p></div>
<span class="msn-11__status" role="status" aria-live="polite">Loaded 8 of 40</span>
</header>
<div class="msn-11__grid">
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1469474968028-56623f02e42e?q=80&w=700&auto=format&fit=crop');--a:oklch(0.75 0.1 130);--b:oklch(0.45 0.11 150)"><div class="msn-11__cap"><strong>Shot 1</strong><span>3.2k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1482938289607-e9573fc25ebb?q=80&w=700&auto=format&fit=crop');--a:oklch(0.72 0.09 230);--b:oklch(0.45 0.1 250)"><div class="msn-11__cap"><strong>Shot 2</strong><span>2.8k likes</span></div></article>
<article class="msn-11__card msn-11__card--w" style="--img:url('https://images.unsplash.com/photo-1501594907352-04cda38ebc29?q=80&w=700&auto=format&fit=crop');--a:oklch(0.7 0.1 40);--b:oklch(0.45 0.13 20)"><div class="msn-11__cap"><strong>Shot 3</strong><span>2.1k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1519125323398-675f0ddb6308?q=80&w=700&auto=format&fit=crop');--a:oklch(0.8 0.07 120);--b:oklch(0.55 0.09 100)"><div class="msn-11__cap"><strong>Shot 4</strong><span>1.9k likes</span></div></article>
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?q=80&w=700&auto=format&fit=crop');--a:oklch(0.7 0.1 140);--b:oklch(0.42 0.1 160)"><div class="msn-11__cap"><strong>Shot 5</strong><span>1.7k likes</span></div></article>
<article class="msn-11__card msn-11__card--w" style="--img:url('https://images.unsplash.com/photo-1472214103451-9374bd1c798e?q=80&w=700&auto=format&fit=crop');--a:oklch(0.8 0.09 90);--b:oklch(0.55 0.12 70)"><div class="msn-11__cap"><strong>Shot 6</strong><span>1.4k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?q=80&w=700&auto=format&fit=crop');--a:oklch(0.74 0.09 220);--b:oklch(0.48 0.1 240)"><div class="msn-11__cap"><strong>Shot 7</strong><span>1.2k likes</span></div></article>
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1433086966358-54859d0ed716?q=80&w=700&auto=format&fit=crop');--a:oklch(0.78 0.08 160);--b:oklch(0.5 0.09 180)"><div class="msn-11__cap"><strong>Shot 8</strong><span>980 likes</span></div></article>
<div class="msn-11__sentinel" aria-hidden="true"><i></i><i></i><i></i></div>
</div>
<footer class="msn-11__foot"><p>You can always reach me — that's the point.</p><a href="#top">Back to top</a></footer>
</div>
</section>
```
## CSS
```css
.msn-11,
.msn-11 *,
.msn-11 *::before,
.msn-11 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.msn-11 {
--bg: oklch(0.98 0.004 250);
--ink: oklch(0.23 0.015 260);
--mut: oklch(0.55 0.015 260);
--line: oklch(0.9 0.008 250);
--acc: oklch(0.6 0.17 300);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
container-type: inline-size;
display: block;
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: var(--bg);
}
.msn-11__stage {
width: 100%;
container: msn11/inline-size;
height: 100vh;
height: 100svh;
overflow-y: auto;
background: var(--bg);
}
.msn-11__head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 15px clamp(16px,3cqi,26px);
background: color-mix(in oklch,var(--bg) 84%,transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.msn-11__head h2 {
font-size: clamp(17px,2.4cqi,22px);
letter-spacing: -.02em;
}
.msn-11__head p {
font-size: 11.5px;
color: var(--mut);
margin-top: 2px;
}
.msn-11__status {
font-size: 11.5px;
font-weight: 700;
color: var(--acc);
border: 1px solid color-mix(in oklch,var(--acc) 35%,var(--line));
background: color-mix(in oklch,var(--acc) 8%,transparent);
border-radius: 99px;
padding: 6px 12px;
font-variant-numeric: tabular-nums;
}
.msn-11__grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill,minmax(min(190px,100%),1fr));
grid-auto-rows: 10px;
padding: clamp(14px,3cqi,26px);
}
.msn-11__card {
position: relative;
border-radius: 13px;
overflow: clip;
background: linear-gradient(150deg,var(--a),var(--b));
}
.msn-11__card::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--img,none);
background-size: cover;
background-position: center;
transition: scale .4s cubic-bezier(.2,.7,.2,1);
}
.msn-11__card:hover::before {
scale: 1.04;
}
.msn-11__card::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg,transparent 55%,oklch(0.2 0.02 260/.6));
}
.msn-11__card--t {
grid-row: span 24;
}
.msn-11__card--s {
grid-row: span 15;
}
.msn-11__card--w {
grid-row: span 10;
}
.msn-11__cap {
position: absolute;
left: 11px;
right: 11px;
bottom: 9px;
z-index: 1;
color: oklch(0.99 0 0);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.msn-11__cap strong {
font-size: 12.5px;
letter-spacing: -.01em;
}
.msn-11__cap span {
font-size: 10.5px;
opacity: .85;
}
.msn-11__sentinel {
grid-column: 1/-1;
display: grid;
grid-template-columns: repeat(auto-fill,minmax(min(190px,100%),1fr));
gap: 10px;
grid-row: span 12;
}
.msn-11__sentinel i {
border-radius: 13px;
background: linear-gradient(100deg,oklch(0.93 0.006 250) 40%,oklch(0.965 0.004 250) 50%,oklch(0.93 0.006 250) 60%) 0 0/240% 100%;
animation: msn11-shimmer 1.3s linear infinite;
}
.msn-11__sentinel i:nth-child(3) {
display: none;
}
@keyframes msn11-shimmer {
to {
background-position: -140% 0;
}
}
.msn-11__sentinel.is-done {
display: flex;
align-items: center;
justify-content: center;
grid-row: span 5;
}
.msn-11__sentinel.is-done::after {
content: "You're all caught up — 40 of 40";
font-size: 13px;
font-weight: 700;
color: var(--mut);
}
.msn-11__sentinel.is-done i {
display: none;
}
.msn-11__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px clamp(16px,3cqi,26px) 26px;
border-top: 1px solid var(--line);
}
.msn-11__foot p {
font-size: 12px;
color: var(--mut);
}
.msn-11__foot a {
font-size: 12.5px;
font-weight: 700;
color: var(--acc);
text-decoration: none;
min-height: 44px;
display: inline-flex;
align-items: center;
}
.msn-11__foot a:hover,
.msn-11__foot a:focus-visible {
text-decoration: underline;
text-underline-offset: 3px;
}
.msn-11__foot a:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 2px;
border-radius: 4px;
}
@container msn11 (width < 460px) {
.msn-11__grid {
grid-template-columns: repeat(2,1fr);
gap: 8px;
}
.msn-11__sentinel {
grid-template-columns: repeat(2,1fr);
}
.msn-11__sentinel i:nth-child(3) {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.msn-11 * {
transition-duration: .01ms !important;
animation: none !important;
}
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.msn-11').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var scroller = root.querySelector('.msn-11__stage');
var grid = root.querySelector('.msn-11__grid');
var sent = root.querySelector('.msn-11__sentinel');
var status = root.querySelector('.msn-11__status');
var BATCH = 8, TOTAL = 40, loaded = 8, busy = false;
var kinds = ['t', 's', 'w', 's', 't', 'w', 's', 's'];
function addBatch() {
var frag = document.createDocumentFragment();
for (var i = 0; i < BATCH; i++) {
var n = loaded + i + 1, hue = (n * 47) % 360;
var el = document.createElement('article');
el.className = 'msn-11__card msn-11__card--' + kinds[(n + i) % kinds.length];
el.style.setProperty('--a', 'oklch(0.78 0.11 ' + hue + ')');
el.style.setProperty('--b', 'oklch(0.5 0.14 ' + ((hue + 45) % 360) + ')');
el.innerHTML = '<div class="msn-11__cap"><strong>Shot ' + n + '</strong><span>' + (2 + (n % 7)) + '.' + (n % 10) + 'k likes</span></div>';
frag.appendChild(el);
}
grid.insertBefore(frag, sent);
loaded += BATCH;
status.textContent = 'Loaded ' + loaded + ' of ' + TOTAL;
if (loaded >= TOTAL) { io.disconnect(); sent.classList.add('is-done'); }
}
// Watch BOTH the stage's own scroll (root: scroller) AND the outer
// viewport (root: null) so infinite scroll fires whether the user
// scrolls inside the fixed-height stage or the outer page/iframe.
// Necessary because when the initial 8-card wall doesn't overflow
// the stage, the outer scroll is the only one the user can trigger.
function onIntersect(entries) {
if (busy || loaded >= TOTAL) return;
var hit = entries.some(function (e) { return e.isIntersecting; });
if (!hit) return;
busy = true;
setTimeout(function () { /* swap this timeout for your fetch() */
addBatch();
busy = false;
// If the sentinel is STILL in view after the batch (large
// stage / small screens / initial full wall fits above the
// fold), keep loading in a chain without waiting for another
// scroll event — that would strand the user staring at the
// shimmer skeleton forever.
if (loaded < TOTAL) {
var r = sent.getBoundingClientRect();
var vh = window.innerHeight || document.documentElement.clientHeight;
if (r.top < vh + 600) setTimeout(function () { onIntersect([{ isIntersecting: true }]); }, 50);
}
}, 550);
}
var ioStage = new IntersectionObserver(onIntersect, { root: scroller, rootMargin: '600px 0px' });
var ioPage = new IntersectionObserver(onIntersect, { root: null, rootMargin: '600px 0px' });
var io = { disconnect: function () { ioStage.disconnect(); ioPage.disconnect(); } };
ioStage.observe(sent);
ioPage.observe(sent);
});
})();
```Here's a working CSS Masonry Layout from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Masonry Infinite Scroll Dynamic Loading
Source: https://codefronts.com/layouts/css-masonry-layouts/css-masonry-infinite-scroll-dynamic-loading/
Infinite scroll done the way the layout gods intended: the wall is demo 03's append-stable grid-span engine (columns would reshuffle every card on each load — the classic library bug, explained), a sentinel row of shimmering skeleton cards trips an IntersectionObserver 600px early, batches append with zero existing-card movement, an aria-live region narrates progress, and the stream ends honestly with 'all caught up' instead of a spinner that lies.
## HTML
```html
<section class="msn-11" aria-label="CSS masonry infinite scroll demo">
<div class="msn-11__stage" tabindex="0">
<header class="msn-11__head">
<div><h2>Discover</h2><p>Endless-ish · stops honestly at 40</p></div>
<span class="msn-11__status" role="status" aria-live="polite">Loaded 8 of 40</span>
</header>
<div class="msn-11__grid">
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1469474968028-56623f02e42e?q=80&w=700&auto=format&fit=crop');--a:oklch(0.75 0.1 130);--b:oklch(0.45 0.11 150)"><div class="msn-11__cap"><strong>Shot 1</strong><span>3.2k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1482938289607-e9573fc25ebb?q=80&w=700&auto=format&fit=crop');--a:oklch(0.72 0.09 230);--b:oklch(0.45 0.1 250)"><div class="msn-11__cap"><strong>Shot 2</strong><span>2.8k likes</span></div></article>
<article class="msn-11__card msn-11__card--w" style="--img:url('https://images.unsplash.com/photo-1501594907352-04cda38ebc29?q=80&w=700&auto=format&fit=crop');--a:oklch(0.7 0.1 40);--b:oklch(0.45 0.13 20)"><div class="msn-11__cap"><strong>Shot 3</strong><span>2.1k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1519125323398-675f0ddb6308?q=80&w=700&auto=format&fit=crop');--a:oklch(0.8 0.07 120);--b:oklch(0.55 0.09 100)"><div class="msn-11__cap"><strong>Shot 4</strong><span>1.9k likes</span></div></article>
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?q=80&w=700&auto=format&fit=crop');--a:oklch(0.7 0.1 140);--b:oklch(0.42 0.1 160)"><div class="msn-11__cap"><strong>Shot 5</strong><span>1.7k likes</span></div></article>
<article class="msn-11__card msn-11__card--w" style="--img:url('https://images.unsplash.com/photo-1472214103451-9374bd1c798e?q=80&w=700&auto=format&fit=crop');--a:oklch(0.8 0.09 90);--b:oklch(0.55 0.12 70)"><div class="msn-11__cap"><strong>Shot 6</strong><span>1.4k likes</span></div></article>
<article class="msn-11__card msn-11__card--s" style="--img:url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?q=80&w=700&auto=format&fit=crop');--a:oklch(0.74 0.09 220);--b:oklch(0.48 0.1 240)"><div class="msn-11__cap"><strong>Shot 7</strong><span>1.2k likes</span></div></article>
<article class="msn-11__card msn-11__card--t" style="--img:url('https://images.unsplash.com/photo-1433086966358-54859d0ed716?q=80&w=700&auto=format&fit=crop');--a:oklch(0.78 0.08 160);--b:oklch(0.5 0.09 180)"><div class="msn-11__cap"><strong>Shot 8</strong><span>980 likes</span></div></article>
<div class="msn-11__sentinel" aria-hidden="true"><i></i><i></i><i></i></div>
</div>
<footer class="msn-11__foot"><p>You can always reach me — that's the point.</p><a href="#top">Back to top</a></footer>
</div>
</section>
```
## CSS
```css
.msn-11,
.msn-11 *,
.msn-11 *::before,
.msn-11 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.msn-11 {
--bg: oklch(0.98 0.004 250);
--ink: oklch(0.23 0.015 260);
--mut: oklch(0.55 0.015 260);
--line: oklch(0.9 0.008 250);
--acc: oklch(0.6 0.17 300);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
container-type: inline-size;
display: block;
width: 100%;
min-height: 100vh;
min-height: 100svh;
background: var(--bg);
}
.msn-11__stage {
width: 100%;
container: msn11/inline-size;
height: 100vh;
height: 100svh;
overflow-y: auto;
background: var(--bg);
}
.msn-11__head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 15px clamp(16px,3cqi,26px);
background: color-mix(in oklch,var(--bg) 84%,transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.msn-11__head h2 {
font-size: clamp(17px,2.4cqi,22px);
letter-spacing: -.02em;
}
.msn-11__head p {
font-size: 11.5px;
color: var(--mut);
margin-top: 2px;
}
.msn-11__status {
font-size: 11.5px;
font-weight: 700;
color: var(--acc);
border: 1px solid color-mix(in oklch,var(--acc) 35%,var(--line));
background: color-mix(in oklch,var(--acc) 8%,transparent);
border-radius: 99px;
padding: 6px 12px;
font-variant-numeric: tabular-nums;
}
.msn-11__grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill,minmax(min(190px,100%),1fr));
grid-auto-rows: 10px;
padding: clamp(14px,3cqi,26px);
}
.msn-11__card {
position: relative;
border-radius: 13px;
overflow: clip;
background: linear-gradient(150deg,var(--a),var(--b));
}
.msn-11__card::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--img,none);
background-size: cover;
background-position: center;
transition: scale .4s cubic-bezier(.2,.7,.2,1);
}
.msn-11__card:hover::before {
scale: 1.04;
}
.msn-11__card::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg,transparent 55%,oklch(0.2 0.02 260/.6));
}
.msn-11__card--t {
grid-row: span 24;
}
.msn-11__card--s {
grid-row: span 15;
}
.msn-11__card--w {
grid-row: span 10;
}
.msn-11__cap {
position: absolute;
left: 11px;
right: 11px;
bottom: 9px;
z-index: 1;
color: oklch(0.99 0 0);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.msn-11__cap strong {
font-size: 12.5px;
letter-spacing: -.01em;
}
.msn-11__cap span {
font-size: 10.5px;
opacity: .85;
}
.msn-11__sentinel {
grid-column: 1/-1;
display: grid;
grid-template-columns: repeat(auto-fill,minmax(min(190px,100%),1fr));
gap: 10px;
grid-row: span 12;
}
.msn-11__sentinel i {
border-radius: 13px;
background: linear-gradient(100deg,oklch(0.93 0.006 250) 40%,oklch(0.965 0.004 250) 50%,oklch(0.93 0.006 250) 60%) 0 0/240% 100%;
animation: msn11-shimmer 1.3s linear infinite;
}
.msn-11__sentinel i:nth-child(3) {
display: none;
}
@keyframes msn11-shimmer {
to {
background-position: -140% 0;
}
}
.msn-11__sentinel.is-done {
display: flex;
align-items: center;
justify-content: center;
grid-row: span 5;
}
.msn-11__sentinel.is-done::after {
content: "You're all caught up — 40 of 40";
font-size: 13px;
font-weight: 700;
color: var(--mut);
}
.msn-11__sentinel.is-done i {
display: none;
}
.msn-11__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px clamp(16px,3cqi,26px) 26px;
border-top: 1px solid var(--line);
}
.msn-11__foot p {
font-size: 12px;
color: var(--mut);
}
.msn-11__foot a {
font-size: 12.5px;
font-weight: 700;
color: var(--acc);
text-decoration: none;
min-height: 44px;
display: inline-flex;
align-items: center;
}
.msn-11__foot a:hover,
.msn-11__foot a:focus-visible {
text-decoration: underline;
text-underline-offset: 3px;
}
.msn-11__foot a:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 2px;
border-radius: 4px;
}
@container msn11 (width < 460px) {
.msn-11__grid {
grid-template-columns: repeat(2,1fr);
gap: 8px;
}
.msn-11__sentinel {
grid-template-columns: repeat(2,1fr);
}
.msn-11__sentinel i:nth-child(3) {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.msn-11 * {
transition-duration: .01ms !important;
animation: none !important;
}
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.msn-11').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var scroller = root.querySelector('.msn-11__stage');
var grid = root.querySelector('.msn-11__grid');
var sent = root.querySelector('.msn-11__sentinel');
var status = root.querySelector('.msn-11__status');
var BATCH = 8, TOTAL = 40, loaded = 8, busy = false;
var kinds = ['t', 's', 'w', 's', 't', 'w', 's', 's'];
function addBatch() {
var frag = document.createDocumentFragment();
for (var i = 0; i < BATCH; i++) {
var n = loaded + i + 1, hue = (n * 47) % 360;
var el = document.createElement('article');
el.className = 'msn-11__card msn-11__card--' + kinds[(n + i) % kinds.length];
el.style.setProperty('--a', 'oklch(0.78 0.11 ' + hue + ')');
el.style.setProperty('--b', 'oklch(0.5 0.14 ' + ((hue + 45) % 360) + ')');
el.innerHTML = '<div class="msn-11__cap"><strong>Shot ' + n + '</strong><span>' + (2 + (n % 7)) + '.' + (n % 10) + 'k likes</span></div>';
frag.appendChild(el);
}
grid.insertBefore(frag, sent);
loaded += BATCH;
status.textContent = 'Loaded ' + loaded + ' of ' + TOTAL;
if (loaded >= TOTAL) { io.disconnect(); sent.classList.add('is-done'); }
}
// Watch BOTH the stage's own scroll (root: scroller) AND the outer
// viewport (root: null) so infinite scroll fires whether the user
// scrolls inside the fixed-height stage or the outer page/iframe.
// Necessary because when the initial 8-card wall doesn't overflow
// the stage, the outer scroll is the only one the user can trigger.
function onIntersect(entries) {
if (busy || loaded >= TOTAL) return;
var hit = entries.some(function (e) { return e.isIntersecting; });
if (!hit) return;
busy = true;
setTimeout(function () { /* swap this timeout for your fetch() */
addBatch();
busy = false;
// If the sentinel is STILL in view after the batch (large
// stage / small screens / initial full wall fits above the
// fold), keep loading in a chain without waiting for another
// scroll event — that would strand the user staring at the
// shimmer skeleton forever.
if (loaded < TOTAL) {
var r = sent.getBoundingClientRect();
var vh = window.innerHeight || document.documentElement.clientHeight;
if (r.top < vh + 600) setTimeout(function () { onIntersect([{ isIntersecting: true }]); }, 50);
}
}, 550);
}
var ioStage = new IntersectionObserver(onIntersect, { root: scroller, rootMargin: '600px 0px' });
var ioPage = new IntersectionObserver(onIntersect, { root: null, rootMargin: '600px 0px' });
var io = { disconnect: function () { ioStage.disconnect(); ioPage.disconnect(); } };
ioStage.observe(sent);
ioPage.observe(sent);
});
})();
```How this works
Two decisions make or break infinite masonry, and both happen before any JavaScript. First: the ENGINE. Multicol balances — append 8 cards and every existing card can migrate columns, teleporting content the user was reading. Grid auto-placement only ever fills forward, so demo 03's row-span lattice makes appends strictly additive: old cards cannot move. Second: the SENTINEL — loading triggers on visibility, not scroll math:
new IntersectionObserver(load, {
root: scroller,
rootMargin: '600px 0px' /* start 600px BEFORE visible */
}).observe(sentinel);The 600px margin means the batch is usually painted before the user reaches it — perceived-infinite, not spinner-infinite. The sentinel itself IS the skeleton row (three shimmer cards), so the loading state needs no insertion or layout shift: it's always there, always last, and simply becomes 'You're all caught up' when the source dries up. The shimmer is one keyframed gradient sweep, silenced by reduced-motion.
Accessibility is three cheap moves most infinite scrolls skip: an aria-live='polite' counter announces 'Loaded 24 of 40' after each batch (screen-reader users otherwise get silent DOM growth); new cards are plain appends so reading order never forks; and the stream ENDS — a footer is reachable, because truly bottomless pages hold keyboard users hostage. The demo simulates network with a 550ms timeout; swap that line for your fetch and keep everything else.
Make it yours
- Batch size & cap: 8 per load, 40 total — both constants at the top of the script. Match batch size to roughly one viewport of cards so each trigger feels seamless.
- Prefetch distance:
rootMargin: 600pxis one comfortable scroll-flick; raise to 1200px on image-heavy feeds where decode time, not fetch, is the bottleneck. - Real data: replace the tile-factory with your fetch → map response to the three span buckets (portrait/square/landscape) server-side, so the client never measures anything.
- Load-more button variant: call the same
addBatch()from a click handler and delete the observer — some products (search results, commerce) genuinely want pagination consent.
Gotchas — read before shipping
- Never build infinite scroll on CSS columns — every append rebalances all columns and the card the user was reading jumps. Engine choice is the fix, not scroll-position hacks.
- The observer's
rootmust be the actual scrolling element — leaving it null (viewport) silently never fires inside an overflow container, the #1 'IO doesn't work' bug. - Guard against double-fires with a
busyflag: fast scrolls can re-trigger before the batch lands and you'll fetch page 3 twice. - Infinite scroll + a footer users can never reach is a lawsuit-grade dark pattern in commerce; cap the stream or add 'Load more' after N auto-batches — this demo stops at 40 on purpose.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 113+ | 111+ |
IntersectionObserver has been universal since 2019; the grid/oklch/container-query floor matches the other demos. No polyfills, no libraries.