Each webpage is composed of various elements. And knowing where these elements are in relation to the viewport can be extremely helpful. For instance, this knowledge allows us to lazy load images, or to load additional content at the bottom of the page (e.g. infinite scroll). This knowledge can also helpful in determining when to trigger animations, or in reporting certain analytic information (e.g. which advertisements have been viewed, or how far down the page a user travels).
But how do you get this information about where a certain element happens to be in relation to the viewport?
For a long time, the primary way of doing this was by using JavaScript to add listeners to certain events (like scroll
or resize
), and then to regularly calculate where the relevant elements were in relation to a predetermined target. Were they entering the viewport? for instance. Or were they within a certain distance from the bottom of the screen or some predetermined element?
Potential Issues
This approach has been around for a while and works fine to a certain extent. The main downside, though, is that it requires regular calculation via JS—any time one of these events are triggered, the listener has to calculate where each specified elements is in relation to the target—and all of these layout-related calculations take place on the main thread. Which means that these calculation could easily turn into a performance problem if not carefully monitored.
This is especially a concern when it comes to the world of advertising, or sites that have multiple 3rd-party scripts. The visibility of each advertisement, for instance, is usually an important metric, and so 3rd-party scripts are bound to have some mechanism for tracking this. Couple that with other scripts that may be doing similar work, and you have a case where all these independent calculations can quickly lead to unwanted jank for the user.
The Intersection Observer API
Fortunately, most browsers now offer the Intersection Observer API, a way of eliminating the need for costly DOM queries, while allowing the browser to provide the position of elements for us. Here’s how it’s described in the W3C spec:
The Intersection Observer API…[gives] developers a new method to asynchronously query the position of an element with respect to other elements or the global viewport. The asynchronous delivery eliminates the need for costly DOM and style queries, continuous polling, and use of custom plugins. By removing the need for these methods it allows applications to significantly reduce their CPU, GPU and energy costs. (https://w3c.github.io/IntersectionObserver/#introduction)
Reducing costly queries, while cutting CPU, GPU, and energy costs? That sounds pretty good. So how does it work?
How Does it Work?
The Intersection Observer API allows us to register a callback when specified elements intersect (enter or exit) one another. This allows us to trigger a callback whenever an element enters or exits (intersects) the viewport, for instance, without needing to set up handlers to constantly calculate where the element is on the page. This not only simplifies things for us, but allows the browser to do the calculations in a way it deems best.
Setting up the Observer
The first thing to do is set up an observer. Doing so requires passing in a callback function as well as options.
var options = {
root: document.querySelector('#container'),
rootMargin: '0px',
threshold: 1.0
}
var callback = function(entries, observer) {
// custom function for when intersection is observed
};
var observer = new IntersectionObserver(callback, options);
Observer Options
When setting up an observer, there a few options that you can pass in. These include:
-
root
- This is the element to use as the viewport. It can be any element, but defaults to thedocument
viewport if not set. -
rootMargin
- This value grows or shrinks the root element bounding box for purposes of computing intersection. For instance, if it’s set to 30px, the target would be considered to be ‘intersecting’ the root when it’s within 30px of it. You have to use either pixels or percentages, and the syntax is the same as the CSS margin property (top left bottom right). -
threshold
- Determines at what percentage visibility the callback will be called. For instance, 0.5 would used to designate that the callback should fire when the target is 50% visible within the root. You can also use an array of numbers to set the callback to fire at different visibility points. For instance [0.25,0.5,0.75] would fire when 25% of the target is visible, again at 50%, and also at 100%. Values can range from 0 to 1.
Callback and Entry Properties
The callback function will be passed an array of the entries that are intersecting, as well as the observer. In this callback you can check the relevant entries to see if any additional work needs to be done.
For instance, if I wanted to add a class to an item whenever it was fully visible in the container, I could do something like this:
var callback = function(entries, observer) {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio == 1 ) {
entry.target.classList.add('active');
} else {
entry.target.classList.remove('active');
}
})
}
In this case I was checking the isIntersecting
and intersectionRatio
properties. Each entry has the following properties which can be of use.
boundingClientRect
intersectionRatio
intersectionRect
isIntersecting
rootBounds
target
time
(A full explanation of each can be found here: https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry)
Regardless of how you use the callback, be aware that it is being called on the main thread, so we should try to make it as lean as possible.
Adding a Target
Once an observer is created, the only thing left is to pass in a target element for it to observe.
// Note: The target element has to be a descendent of the root element
// Single element
var target = document.querySelector('#target');
observer.observe(target);
The observer only takes a single element as an argument, so if you need to track multiple targets, you’ll need to (loop through them and) add each one individually.
// Multiple elements
var targets = document.querySelectorAll('.target');
items.forEach(target => {
observer.observe(target);
});
Observer Methods
In addition to observe()
, the Observer also offers the following methods:
disconnect()
- This tells the observer to stop observing any of the targets associated with it.takeRecords()
– This returns a list of all observed targets, regardless of whether they are intersecting.unobserve(target)
- This tells the observer to stop observing a single target element.
Using unobserve()
, for instance, could be handy in removing the observer from a targer once the callback has been called.
Support
The Intersection Observer API has been out for a little while now, and is supported by most of the main browsers. The main exceptions are IE and Safari. Thankfully, there is a polyfill that provides support for these exceptions, as well as legacy browsers.
Conclusion
Not only does the Intersection Observer API simplify what needs to be done to track where elements are in relation to the viewport, but it also provides a way to offload all those calculations to the browser. This paves the way for more efficient, more performant applications, which is good thing all the way around.
Resources
- Intersection Observer API, MDN Docs
- IntersectionObserver polyfill, W3C
- IntersectionObserverEntry, MDN Docs
- Timing element visibility with the Intersection Observer API, MDN Docs. Example of how the Intersection Observer API can be used in a real-situation, timing how long ads on a page are visible, and them swapping them out appropriately.
- Lozad.js, a lazy-loading library using the Intersection Observer API