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.

Viewport Root Versus Panel Root for a Dropdown ListTwo columns. With the viewport as root, a dropdown near the top of the window keeps its sentinel inside the viewport, so after one load no new crossing occurs, and on small screens loading depends on page scrolling. With the panel as root, crossings follow the panel's own scroll position and percentage margins scale with the panel.root: null (viewport)Sentinel stays in the viewport near the top of thepageOne load at mount, then no new crossingsOn small screens, depends on page scrollroot: panelCrossings follow the panel's own scrollMargins as % of panel heightIndependent of where the panel sits on the page

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

TypeScript
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

TypeScript
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.

Filling a Short PanelFive steps. The observer reports the sentinel visible at mount. The first page loads and is appended. After a frame, the sentinel is still within range because the list does not yet fill the panel, so another page loads. After the next frame the sentinel has moved out of range and the observer reports it not visible, so loading stops. Further loads happen only when the user scrolls the panel.1MountSentinel visible: load page 1.2Page 1 appendedPanel still not full.3Re-check next frameSentinel still in range: load page 2.4Panel overflowsObserver reports not visible; loading pauses.5User scrolls panelSentinel re-enters range; next page loads.

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:

  1. Create the observer after the panel is visible. A display: none root has no box; the observer works but reports nothing useful until it is shown. Creating it in the "opened" handler is simplest.
  2. Disconnect when the panel closes. A closed dropdown's sentinel should not trigger loads, and a disconnected observer costs nothing.
  3. 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.

Dropdown Lifecycle and the ObserverFour boxes. The dropdown opens and the observer is created with the listbox as root. A new query resets the done flag, clears the list and re-observes the sentinel. The user scrolls within the listbox and pages load. The dropdown closes and the observer is disconnected.Opencreate observer, root =listboxNew queryreset(): clear,re-observeScroll in listboxpages loadClosedisconnect

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.rootBounds matches 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.


↑ Back to Infinite Scroll & Pagination with IntersectionObserver