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?