For an infinite list inside a scrollable panel, pass the panel as the observer's root and express the pre-load distance as a rootMargin percentage of the panel, then handle the two container-specific cases the page-level version never meets: a panel with no size yet, and a first page too short to scroll.
Problem / Scenario Context
A product-search dropdown shows results in a panel with max-height: 60vh; overflow-y: auto and should load more as the user scrolls within it. The team copies the page-level infinite scroll with root: null. On desktop, results never load past the first page: the dropdown sits near the top of the window, so its bottom sentinel is always inside the viewport — the observer fires once at mount and never again. On mobile, where the dropdown is pushed below the fold, it fires only when the whole page scrolls, not when the panel does.
The frame of reference is the panel, not the window. The Infinite Scroll & Pagination topic covers the general pattern; this page adapts it to containers, building on using a scroll container as IntersectionObserver root.
Mechanics Explanation
With root: null, the sentinel is clipped by the panel and then intersected with the viewport. While the sentinel is scrolled out of the panel's visible area, the clip removes it and it is "not intersecting" — so far so good. But the observer only delivers entries when the state changes. If the panel is short and its content does not yet overflow, the sentinel is visible from the start and stays visible; no further crossings occur, and no further loads are triggered even after new items are appended — unless the append pushes the sentinel out of view and back.
With root: panel, the root rectangle is the panel's padding box. rootMargin extends that box, so '0px 0px 50% 0px' means "half a panel-height below the panel's visible bottom", which scales with the panel rather than the screen. Crossings are relative to the panel's own scroll position.
Either way, one situation remains: a first page that does not fill the panel. The sentinel is visible at mount, one load fires, the list grows but still does not fill the panel, and the sentinel never leaves and re-enters. The fix is to re-check after every load.
Comparison Table: Container Configurations
| Container | root |
rootMargin |
Extra handling |
|---|---|---|---|
| Page-level feed | null |
'800px 0px' |
none |
Sidebar / panel with overflow: auto |
the panel | '0px 0px 100% 0px' |
re-check after load |
| Modal body | the modal's scroller | '0px 0px 50% 0px' |
create after the modal opens |
| Dropdown / combobox listbox | the listbox | '0px 0px 50% 0px' |
reset on new query |
| Horizontally scrolling rail | the rail | '0px 100% 0px 0px' |
inline axis |
Minimal Reproducible Example
const panel = document.querySelector<HTMLElement>('.results')!; // max-height + overflow-y: auto
const sentinel = panel.querySelector<HTMLElement>('.sentinel')!;
new IntersectionObserver(([e]) => {
if (e.isIntersecting) loadMore(); // viewport root
}, { rootMargin: '400px' }).observe(sentinel);
declare function loadMore(): Promise<void>;
Open the dropdown near the top of a desktop window: one extra page loads, then scrolling within the panel does nothing.
Production-Safe Solution
interface PanelScrollOptions {
panel: HTMLElement;
sentinel: HTMLElement;
loadMore: () => Promise<boolean>; // resolves false when there is nothing more
ahead?: string; // % of panel height
}
export function panelInfiniteScroll({ panel, sentinel, loadMore, ahead = '100%' }: PanelScrollOptions): {
reset: () => void; stop: () => void;
} {
let loading = false;
let done = false;
let visible = false;
async function maybeLoad(): Promise<void> {
if (loading || done || !visible) return;
loading = true;
try {
const more = await loadMore();
if (!more) { done = true; return; }
} finally {
loading = false;
}
// The list may still not fill the panel: if the sentinel is still in range, go again.
// Wait one frame so layout reflects the appended items before the observer re-evaluates.
requestAnimationFrame(() => { void maybeLoad(); });
}
const io = new IntersectionObserver(([e]) => {
visible = e.isIntersecting;
void maybeLoad();
}, { root: panel, rootMargin: `0px 0px ${ahead} 0px` });
io.observe(sentinel);
return {
reset(): void { // new search query: start over
done = false;
io.unobserve(sentinel);
io.observe(sentinel); // fresh initial entry re-evaluates visibility
},
stop(): void { io.disconnect(); },
};
}
visible tracks the sentinel's latest state from the observer, and maybeLoad re-checks it after each load. If the appended page moved the sentinel out of range, the observer will have delivered isIntersecting: false by the next frame and the loop stops; if the panel is still not full, it loads again. reset() handles the dropdown case where a new query clears the list: re-observing produces a fresh initial entry without creating a new observer.
Panels That Open and Close
Dropdowns, modals and collapsible sidebars have a lifecycle the page does not: they can be created hidden (display: none), shown, hidden again, and their content replaced. Three rules keep the observer correct:
- Create the observer after the panel is visible. A
display: noneroot has no box; the observer works but reports nothing useful until it is shown. Creating it in the "opened" handler is simplest. - Disconnect when the panel closes. A closed dropdown's sentinel should not trigger loads, and a disconnected observer costs nothing.
- Reset on new content. When a new query replaces the results, the scroll position and "done" state belong to the old query. Reset both, and re-observe to get a fresh entry.
For accessibility, the listbox should announce result counts politely and keep keyboard focus stable as pages load — see announcing infinite scroll loads with aria-live.
Verification Steps
- Open the panel near the top of a large window and scroll within it; pages must keep loading.
- Use a query with very few results per page and confirm the panel fills itself until it overflows.
- Change the query and confirm loading restarts from page 1 with no stale "done" state.
- Close and reopen and confirm no loads happen while closed.
- Check
entry.rootBoundsmatches the panel, not the window.
Common Mistakes to Avoid
- Using the viewport root for a panel list. Crossings depend on where the panel sits on the page.
- Assuming one load per crossing fills the panel. Short pages need a re-check loop.
- Creating the observer while the panel is
display: none. The root has no box yet. - Keeping state across queries. "Done" and scroll position belong to one query.
FAQ
Why does my panel list load only once?
Usually because the sentinel stayed visible after the first load, so no new crossing occurred. Use the panel as root and re-check visibility after each load.
Do percentage margins refer to the panel or the viewport?
To the root. With the panel as root, 50% means half the panel's height, which scales naturally between small dropdowns and tall sidebars.
Can the panel itself be off-screen?
Yes, and the observer does not care: it only compares the sentinel with the panel. If the panel should not load while off-screen, gate it with a second observer on the panel using the viewport root.
Should I wait for a frame before re-checking?
Yes. The observer needs a rendering update to recompute the sentinel's position after the append. Re-checking on the next frame uses the updated visibility instead of the stale value.
What about virtualised dropdowns with thousands of options?
Combine windowing with the loader: the virtual list keeps the DOM small, and the sentinel at the end of the rendered window triggers fetching more data. See the virtual list topic for the windowing side.
Does this work for horizontal rails?
Yes, with the margin on the inline end: '0px 100% 0px 0px' pre-loads one rail-width ahead to the right in left-to-right layouts.
Related
- Bidirectional Infinite Scroll: Loading Upward — the other direction
- Preventing Duplicate Fetches in Observer-Driven Pagination — guarding the loader
- Using a Scroll Container as IntersectionObserver Root — root mechanics
↑ Back to Infinite Scroll & Pagination with IntersectionObserver