Zero Sized Intersection Observers

Apr 06, 2024

A common strategy to implement infinite-scroll is to place a marker element at the end of a scroll container. When the user scrolls to the bottom of the container, the marker element becomes visible and triggers an IntersectionObserver callback, at which point you can fetch the next page of data.

<ul>
  <li>One</li>
  <li>Two</li>
  ...
  <li>Fifty</li>
  <div id="loadMore"></div>
</ul>
const target = document.getElementById('loadMore');
const observer = new IntersectionObserver((entries) => {
  if (entries.some(e => e.isIntersecting)) {
    // fetch next page
  }
});
observer.observe(target);

Since you usually don’t want the marker element to be visible to the user, it is common to leave it empty. Depending on your browser, this will seem to work just fine.

However, if we read the IntersectionObserver spec carefully, we see that the default threshold value of 0 signifies an intersection of “any non-zero number of pixels”. This means that (in browsers that satisfy the spec) our callback will never fire because the target is zero pixels wide and zero pixels tall.

The w3c seems to have added the isIntersecting Flag partially for this purpose. But never clearly disambiguated how zero-sized elements should interact with the threshold in order to trigger the observer callback. Because of this, we see platform-dependent behavior with this edge case.

According to my testing, the code above will trigger the observer callback on MacOS (Firefox and Chrome) but not on Windows (Chrome). In order to ensure consistent behavior across platforms, developers should always add some content to elements observed by the IntersectionObserver API.

I would recommend an implementation that can fall back to a plain button if the user’s browser does not support the IntersectionObserver API.

<ul>
  <li>One</li>
  <li>Two</li>
  ...
  <li>Fifty</li>
  <button id="loadMore">Load More</button>
</ul>
const loadNextPage = () => {
  /// ...
};
const target = document.getElementById('loadMore');
target.onclick = () => {
  loadNextPage();
};
if ('IntersectionObserver' in window) {
  target.style = 'visibility: hidden';
  const observer = new IntersectionObserver((entries) => {
    if (entries.some(e => e.isIntersecting)) {
      loadNextPage();
    }
  });
  observer.observe(target);
}