How to Fix Layout Shift on Infinite Scroll and "Load More" Lists
This guide tackles growing lists, within Reducing Cumulative Layout Shift (CLS) in Core Web Vitals & Measurement. Feeds, search results and product grids that load more content as the user scrolls are a common source of CLS that only appears in long sessions — which is why it shows up in field data but rarely in a single lab load.
Appending items to the end of a list should not shift anything the user can see: new content appears below. In practice, three things go wrong. The footer is visible when new items arrive and gets pushed down. A loading spinner is inserted and removed, moving content around it. And items already in view resize as their images load late. Each of these happens without a discrete user input — scrolling does not count as one — so every shift contributes to CLS.
Rapid Diagnosis
- Scroll slowly through several pages in a recorded trace with the Layout Shift Regions overlay on. Note whether flashes happen at the bottom (footer, spinner) or in the middle (items resizing).
- Check when the next page is requested. If the request starts only when the sentinel reaches the viewport, new items arrive while the bottom of the list — and the footer — is visible.
- Inspect the loading indicator. Is it inserted into the DOM when loading starts and removed when it ends? Each insertion and removal changes height.
- Check item media. Images in appended items without
width/heightoraspect-ratiocollapse and then expand. - Check "Load more" buttons. If the button's click triggers loading, shifts within 500ms are excluded; content that arrives after that window is not.
Root Cause Analysis
1. Visible footer pushed down. When the user reaches the end, the footer is in view. New items inserted above it push it down — a large shift of a full-width element.
2. Spinner insertion and removal. A spinner element added below the list adds its height; removing it after items render removes that height again. Two shifts per page load.
3. Late-loading media inside appended items. Items arrive with text first; images load afterwards and expand cards that are already visible.
4. Load-more responses outside the input window. A "Load more" click excuses shifts for 500ms. A slow API response arriving after 800ms inserts content with no recent input — counted.
Step-by-Step Resolution
1. Prefetch the next page before the user reaches the end
Trigger loading well before the sentinel is visible, so new items are inserted below the viewport, where they cannot shift anything visible.
const sentinel = document.querySelector('#list-sentinel');
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && !loading && hasMore) loadNextPage();
}, { rootMargin: '0px 0px 1500px 0px' }); // fire ~1.5 screens early
io.observe(sentinel);
// trade-off: a large rootMargin loads pages the user may never scroll to,
// costing bandwidth and API capacity. Tune it per device: smaller on slow
// connections where data cost matters more than smoothness.
Expected outcome: in normal scrolling, appends happen off-screen and generate no visible shifts.
2. Give the loading indicator a permanent, fixed-height slot
<div class="list-status" aria-live="polite" style="min-height: 4rem">
<!-- spinner or "Loading more…" text appears here; the slot never changes height -->
</div>
<!-- trade-off: the slot occupies space even when idle. Use it for the
"End of results" message once there are no more pages, so the space
always carries meaning. -->
Expected outcome: no shifts from spinner insertion or removal.
3. Keep the footer out of the shift path
For true infinite scroll, the footer is unreachable in practice — do not render it under the list, or move its links into a sidebar or a "Load more" pattern. For load-more lists, the footer is visible only when the user is at the end; inserting new items pushes it down, but if the click triggered the load and items arrive within 500ms, it is excluded.
async function onLoadMoreClick() {
const res = await fetch(nextUrl, { priority: 'high' }); // fast path for user-initiated loads
const items = await res.json();
renderItems(items); // insert within the input window where possible
}
// trade-off: you cannot guarantee a 500ms response. For slow APIs, insert the
// new items' reserved slots immediately on click (skeletons of the right size)
// so the layout change happens inside the exclusion window, then fill them.
Expected outcome: user-initiated appends land inside the exclusion window, or reserve their space there.
4. Reserve dimensions for every item's media
Every image, video and embed in list items needs intrinsic dimensions from the API response so its box is correct before it loads.
<img src={item.image.url} width={item.image.width} height={item.image.height}
loading="lazy" decoding="async" alt={item.image.alt} />
// trade-off: requires your API to return image dimensions. If it cannot, use a
// fixed aspect-ratio container and object-fit: cover, accepting some cropping.
Expected outcome: appended items never change size after insertion.
Verification
Script a long session in Playwright that scrolls slowly through ten pages, and assert the observed CLS stays below 0.05. Run it on a throttled network so responses are slow enough to reproduce real conditions.
await page.goto('/search?q=boots');
await page.evaluate(() => { window.__cls = 0; new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cls += e.value; })
.observe({ type: 'layout-shift', buffered: true }); });
for (let i = 0; i < 10; i++) { await page.mouse.wheel(0, 1200); await page.waitForTimeout(800); }
expect(await page.evaluate(() => window.__cls)).toBeLessThan(0.05);
// trade-off: summing all shifts overstates CLS compared with the windowed
// definition, which makes this a conservative test — good for a CI gate.
In RUM, chart CLS by session length or scroll depth; after the fix, deep-scrolling sessions should no longer have markedly worse CLS than shallow ones.
Choosing Between Infinite Scroll, Load More and Pagination
From a CLS standpoint, the three patterns differ in how much control you have. Pagination replaces the whole list on navigation — a new route — so shifts are confined to that view's initial render. "Load more" ties appends to a click, giving you the 500ms exclusion window to work within. Infinite scroll has no input at all, so it relies entirely on prefetching and space reservation to stay shift-free. Infinite scroll also has accessibility and footer-reachability costs. For search results and product listings, "Load more" with prefetching on hover of the button is often the best balance: users get continuity, the footer stays reachable, and every append is input-triggered.
FAQ
Does virtualisation cause layout shift?
Virtualised lists add and remove DOM nodes as you scroll, but they position items absolutely or with transforms inside a container whose total height is known, so visible content does not move. Problems arise when item heights are estimated and corrected after render — the correction moves items. Measure heights once and cache them, or use fixed heights. See virtualizing long lists in React.
Is scrolling considered user input for CLS?
No. Only discrete inputs — clicks, taps, key presses — set hadRecentInput. Scrolling is continuous and does not excuse shifts. That is why infinite scroll needs space reservation and early loading rather than relying on the exclusion window.
Should new items fade in to soften their arrival?
A fade with opacity is fine — it does not change layout, so it adds no shift. What matters is that the items' boxes are inserted at their final size in one step. Avoid entrance effects that animate height or max-height from zero, which turn one insertion into a shift on every frame, and avoid staggered insertions where each item is added separately over several hundred milliseconds.
Related
- Fixing CLS from skeleton screens that resize — sizing the placeholders inserted on load.
- Native lazy loading vs IntersectionObserver — the loading strategy for appended item images.
- Why SPA CLS accumulates across routes — long sessions and CLS windows.