The Intersection Observer API explained.

Geometrical shapes intersecting
Geometrical shapes intersecting each other.

What is the Intersection Observer?

The Intersection Observer is a Web API that facilitates visibility tracking of elements within the viewport or its parent element(s). Some practical use cases could be lazy loading for images, dynamic imports based on interaction, scroll-spying navigation bars, among others. This API encapsulates a lot of logic that before its time, a developer would’ve had to build from scratch with several performance considerations in mind — believe me, mastering how to use the Intersection Observer is 100% worth it.

Even though the Intersection Observer has been available for a while, its documentation can be a bit hard to follow and sometimes confusing, and in spite the fact that I’ve been using it for a while on most of the projects I’ve worked on, I usually have to go back to the documentation, re-read it, re-understand it and sometimes even do a quick POC to figure what do I actually need.

Life before the Intersection Observer

Before we start I want to acknowledge how was life before this API existed. We know that strategies like lazy loading or scroll-spying have existed for a while, and in order to achieve this, developers had to take into account several techniques and best practices to make it work smoothly and in a performant way. Some of the main complexities orbited around:

  • Scroll event handling
  • Avoiding Layout Thrashing
  • Rendering performance
  • Clear understanding of requestAnimationFrame
  • Identifying Layout Problems and performance bottlenecks (performance tab on Chrome DevTools)

After jumping through some hoops, developers were able to achieve what the Intersection Observer seamlessly does. Now, don’t get me wrong, if you nail all of the above (and a couple more) you could create something as performant (or more) as the Intersection Observer. So if you’re up for the challenge, do it (let me know if this is something you’d be interested to read about in the comments).

But let’s not forget that this is adding a good amount of code to your codebase, it’s more code to be maintained and–hopefully–to be tested.

🚀 The power of the Intersection Observer

The Intersection Observer hides many implementation details and includes the different considerations we just talked about. Everything is boiled down into a class: IntersectionObserver.

By instantiating an IntersectionObserver you get an instance of an observer that knows how to detect intersections between a target element and its direct or indirect parent, being the browser viewport the highest level one (and default). All of this based on a configuration. This is when things start to get tricky… the configuration.

Learning how to configure the Intersection Observer

The configuration changes drastically how the Intersection Observer behaves, this is why I took the time to build a tool to better understand its behavior and provide an easy way to try different configurations by using UI controls. It’s mind blowing to see how a simple 1 instead of a 0 as threshold impacts the functioning of this powerful tool. The Intersection Observer Playground looks something like the following GIF (or GIF, I don’t know how to pronounce it):

The Intersection Observer playground reacting to different configurations showing and hiding cards.

The scope of this tool is to showcase how a threshold: 0or threshold: 1 interacts with negative root margins (using either pixels or percentages). These terms might sound confusing so let’s dive in.

Relevant terms

👀 Observer

An instance of the IntersectionObserver that will be detecting intersections.

🎯 Target

The element being observed by the said observer. The same observer can observe several targets.

🌳 Root configuration (root parameter)
The ancestor used to detect the intersection. In order to an intersection to happen the target needs to be a descendant of the root. By default the root is the viewport, as seen in the following image:

Default root of the Intersection Observer. The

As a recurrent user of this API I would say that in most of the cases the default root works like a charm.

🌗 Threshold (threshold parameter)

Picture this as sort of checkpoints along the area of the target. This checkpoints go from 0 to 1. Float values are valid. So as an example:

  • threshold: 0: will trigger the callback only when the intersection of the target happens and 99.99% of its area is outside of the root.
  • threshold: 1: will trigger the callback only when the intersection of the target happens and 99.99% of its area is inside the root.

I want to highlight the “when the intersection of the target happens” part of both items above. The IntersectionObserver only triggers when there are intersections happening.

See the following image as a reference:

In this image there are two different Intersection Observer instances with two different configurations. The bottom left-hand side target will trigger an intersection as soon as it starts to peek, and the top right-hand target will trigger an intersection when it is completely visible.

↕️ RootMargin (rootMargin parameter)

Remember the CSS Box Model? Well this API applies a similar principle to the root. This provides the ability to customize where the intersections are detected. As an example let’s look at this image adding the following configuration: rootMargin: -100px 0px -250px 0px and how the intersection positions change:

Adding `rootMargin` delays the trigger of the Intersection Observer closer to the vertical center of the viewport.

A similar result can be achieved by using percentages instead of pixels:

Notice how using percentages now the height of the rootMargins depend on the height of the viewport. If the height of the root (viewport) is 900px then 20% would be 180px. This strategy is very useful if we want the rootMargin to be dynamic.

Using the Intersection Observer

The minimum you need to do to use this API is to instantiate it and observe a target.

const callback = (entries, observer) => {};
const observer = new IntersectionObserver(callback);
const target = document.querySelector('.invisible');

The callback

The callback is a good old JavaScript function that receives two parameters:

const callback = (entries, observer) => {};
  • entries: a list of IntersectionObserverEntry objects. In other words, a list of the elements being observed having an intersection accompanied with relevant data to make decisions about the intersection (i.e. isIntersecting or intersectionRatio; the former to know if the object is actually intersecting and the latter to know the visible percentage of the target).
  • observer: the instance of the IntersectionObserver used to detect the intersections. Mostly used to unobserve if the action was a one-time thing.

The callback will be called in two scenarios:

  1. Right after calling the observe method the browser will try to find an idle period and will execute the callback regardless of whether the target is currently intersecting or not with the root. This will happen only once
  2. Every time the target intersects with the root based on the configurations. This can happen multiple times

The IntersectionObserverEntry

Whenever the callback gets called, an array of entries will be received and there are a several attributes that can be used. Nevertheless, let’s focus on two:

  1. isIntersecting: boolean value that will be set to true if the target is currently intersecting. This is the most straightforward way to validate the visibility.
  2. intersectionRatio: number value between 0 and 1 tied to the ratio that’s being visible while intersecting with the root. This one is useful if there are several thresholds configured (this can be a more advanced post in the future).

By understanding the different concepts we’ve just covered, you should be able to achieve a myriad of ideas and interactions. There are infinite combinations that can be done between the threshold, the rootMargin, and the implementation of the callback using isIntersecting and intersectionRatio.

Use cases

I encourage you to play around with the tool mentioned above and try to find the different configurations that might work for your specific case. Here I’ll try to list some configurations I’ve used for different cases:

Spy navigation

This configuration aims to solve the typical navigation that sets an active state to the items in it depending on the section being visible as the user scrolls:

const configuration = {
threshold: 0,
rootMargin: '0% 0% -100% 0,
const callback = (e, o) => {
e.forEach((entry) => {
if (entry.isIntersecting) {
// Add logic for nav item that maps to the visible section.
} else {
// Add logic for nav item that maps to the invisible section.
const observer = new IntersectionObserver(callback, configuration);[...document.querySelectorAll('.spy-on')].forEach(
(elementToSpy) => observer.observe(sectionsToSpy)

This brief implementation will execute the callback every time a section touches the top edge of the viewport as the user scrolls up, therefore the rootMargin top is 0% and bottom is -100%. Now it’s on you to come up with a way to map the section to the navigation item to toggle its state.

Image lazy loading

Lazy loading images require a slightly different approach as requesting an image can take long depending on its weight. So we’d need to do a different configuration:

const configuration = {
threshold: 0,
rootMargin: '50% 0% 50% 0,
// The rest of the implementation is fairly similar to the spy nav.

Notice how the rootMargin is now positive. Even though this tool doesn’t provide a way to make it happen, as we discussed above, this behaves similarly to the box model. Negative margins grow inward, positive margins grow outwards. This means that the images will trigger an intersection 50% outside of the viewport. See the following image as a reference:

Positive rootMargin grows outside of the viewport and allows for the intersection to be detected outside of the viewport. Notice how images above and below the screen intersect with the rootMargin executing the callback, thus requesting it lazily.

The rootMargin 50% is a number that can be adjusted depending on the weight of the images or specific needs that you may have.

Dynamic imports

The approach for dynamic imports is similar to the lazy load of the images. Although, if code splitting is done right the files should be small if not tiny so the number could decrease from 50% to a 10%.

const configuration = {
threshold: 0,
rootMargin: '50% 0% 50% 0,
// The rest of the implementation is fairly similar to the rest.

Using this configuration will allow you to detect the components that are about to be shown on the screen outside of the viewport. Giving you enough time to dynamically import it and then use it.

Scroll can be considered an interaction, but remember to only import what’s really needed. For example a modal should not get imported on scroll, but on actual click interaction. If you want to read more I recommend the insightful “Import on Interaction” blog post by Addy Osmani.

Animate in/out (fade or slide)

State of the art web pages have a lot of fading in/out depending on the scroll position and what’s visible. Go ahead and click around on the Intersection Observer Playground and try to figure out what’s the proper configuration to make this happen.

Consider Reduced Motion

Please always remember to implement a reduced motion version of the interactions being built. Let’s always push for an accessible web world.

Thanks so much for the read. Please feel free to reach out if you have any questions or concerns. Happy to connect.



Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Wilmer Soto

Wilmer Soto

I am a passionate web developer, eager to share my day to day learnings and stories in the industry. Currently working at @hugeinc.