38

I have a little issue with the Intersection Observer API, It works good for me, but.

When I scroll fast (very fast) on my webpage, the Intersection Observer API fails sometimes to detect the pretense of an element in the viewport.

When I scroll slow/normally it works for all elements

Observer options:

RootMargin: 0px 0px -40px 0px,
threshold: 0.7,
root: null

Height of elements : between 100px and 200px

Anyone know why?

Or Assayag
  • 5,662
  • 13
  • 57
  • 93
Zarma Tech
  • 381
  • 1
  • 3
  • 4
  • 2
    I haven't heavily tested this, but avoid adding rootMargin, its the reason the observer doesn't fire when scrolling fast. Instead use sentinels. In this context, sentinel is a div element that is absolutely positioned and is the one that your observe on. – Caleb Taylor May 21 '21 at 20:59

2 Answers2

26

Intersection Observer runs an asynchronous function in a loop which checks the position of observed DOM elements. It's coupled with the browser's render cycle and although it's happening very fast (60fps for most devices, or once every 16.66 miliseconds), if you move the scrollbar faster than those checks happen, the IO API may not detect some visibility changes. In fact, the elements which have not been detected haven't even been rendered.

Which makes sense, because the primary goal of the IO is to check if an element is visible to the human eye. According to the Intersection Observer spec the aim is to provide a simple and optimal solution to defer or preload images and lists, detect e-commerce ads visibility, etc.

That being said, Intersection Observer is a long awaited feature and it solves a great deal of problems, but it is not ideal for all use cases. If you can live with the fact that in high-velocity scrolling situations it will not capture some intersections - great. If not, well, there are other options.

Solution 1: Inferring intersecting elements

One way of handling this issue is trying to indirectly infer which elements crossed the viewport but were not captured by the Intersection Observer. To implement it, you will need to give a unique numeric attribute to all your list elements in an ascending order. Then, within each Intersection Observer's callback function, save the min and max IDs of intersecting elements. At the end of the callback, call setTimeout(applyChanges, 150) to schedule a function which will loop through all elements with IDs between min and max were not omitted by the IO. Also, put clearTimeout() at the beginning of callback, to ensure that this function waits until IO is inactive for some small amount of time.

let minId = null;
let maxId = null;
let debounceTimeout = null;

function applyChanges() {
  console.log(minId, maxId);
  const items = document.querySelectorAll('.item');
  // perform action on elements with Id between min and max
  minId = null;
  maxId = null;
}

function reportIntersection(entries) {
  clearTimeout(debounceTimeout);
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const entryId = parseInt(entry.target.id);
      if (minId === null || maxId === null) {
        minId = entryId;
        maxId = entryId;
      } else {
        minId = Math.min(minId, entryId);
        maxId = Math.max(maxId, entryId);
      }
    }
  });
  debounceTimeout = setTimeout(applyChanges, 500);
}

const container = document.querySelector('#container');
const items = document.querySelectorAll('.item');
const io = new IntersectionObserver(reportIntersection, container);
let idCounter = 0;
items.forEach(item => {
  item.setAttribute('id', idCounter++);
  io.observe(item)
});

Solution 2: Throttling scroll events

There’s no possibility to influence the default scrollbar behaviour, but you could override it with a custom scrollbar. The idea is to throttle the scroll event, so that the container can’t scroll more than its own height every render cycle. This way, the IntersectionObserver will have time to capture all intersections. Although I’d recommend using existing libraries for this task, below is a rough logic of how it should work.

First, define the maximum distance a container can scroll within a single render cycle: let scrollPool = 500. On each scroll event, schedule resetting the scrollPool to the original value during next render in requestAnimationFrame() function. Next, check if the scrollPool is less than 0 and if it is, return without scrolling. Otherwise, subtract the scrollDistance from the scrollPool and add the scrollDistance to the container’s scrollTop property.

Again, this solution might be an overkill to implement because of the multitude of ways a user can scroll a page. But in order to depict the idea here's a draft for the wheel event:

#container { overflow: hidden; }
let scrollPool = 500;

function resetScrollPool() {
  scrollPool = 500;
}

function scrollThrottle(event) {
  window.requestAnimationFrame(resetScrollPool);
  if (scrollPool < 0) {
    return false;
  }
  const scrollDistance = event.deltaY * 10;
  scrollPool = scrollPool - Math.abs(scrollDistance);
  document.querySelector('#container').scrollTop += scrollDistance;
}

window.addEventListener('wheel', scrollThrottle);

Solution 3: Implementing custom intersection detection

This would mean ditching the IntersectionObserver altogether and using old methods. This would definitely be much more costly performance-wise, but you would be sure to capture all elements which crossed the viewport. The idea is to add a scroll handler function to loop through all children of the container to see if they crossed the visible area. For improved performance, you might want to debounce this function by a second or-so. A great implementation can be found in: How can I tell if a DOM element is visible in the current viewport?

83C10
  • 1,112
  • 11
  • 19
  • 3
    Nice! Options 1 sounds like the best one. – trusktr May 22 '21 at 01:22
  • 1
    for option 1: you have to add an id tag that goes sequential, else entryId is basically empty, one way to circumvent would be to add one in this code itself: let cntr = 0; items.forEach(item => {item.setAttribute('id', cntr++);io.observe(item);}); – A G Jun 14 '21 at 17:20
  • 1
    @AG Thanks for this suggestion. I've included it in the code example. Cheers – 83C10 Jun 16 '21 at 09:28
  • Additional solution: Exploit the user's tendency to try to interact with what doesn't seem to work to force your function to run on certain user interactions. In my case, I added an event listener on `mouseenter` and `click`, and it worked pretty well. People go "this is slow, what's wrong?" and they'll almost universally at least put their mouse cursor over it, if they didn't already in the process of scrolling. Since it's a rare occurrence, it's good enough for me. – Ariane Oct 07 '21 at 19:01
2

Testing tip:

I find the following to be a very good way to test intersection observer when scrolling at different speeds on a real device.

// set theme color when intersector considers the item 'visible'
const metaThemeColor = document.querySelector("meta[name=theme-color]");
metaThemeColor!.setAttribute("content", visible ? 'limegreen' : 'red');

This sets the 'accent' color, which on iOS is the color surrounding the clock and battery icon area.

You can scroll up and down and see if or when green or red gets shown. As far as I can tell there's no noticable delay when setting the accent color.

To test how fast you need to scroll to completely skip a section you can use the following which resets back to white after a couple seconds. Scroll super fast and it'll never turn green.

const metaThemeColor = document.querySelector("meta[name=theme-color]");
if (visible) 
{
   metaThemeColor!.setAttribute("content", 'limegreen');
   setTimeout(() => metaThemeColor!.setAttribute("content", 'white'), 2000);
}

For my use case I've got a video player and I want the play button to fade in once the player is visible (to encourage people to hit play). Unfortunately iOS seems to be a bit slow (deliberately it seems) sometimes and since the play button fades in the effect is less than desirable. Chrome on Windows is instantaneous. However I think for actual users that would be patient enough to watch the video in the first place the effect wouldn't matter.

Simon_Weaver
  • 140,023
  • 84
  • 646
  • 689