A code snippet, showcasing the Intersection Observer API

Let go of your scroll handler


In web applications, performance is extremely important but often overlooked. Underneath beautiful animations and cool interactions, there may be "performance thieves" hiding. Scroll-attached effects can be such a thief - but is there another solution? Let's find out!

Estimated read time : 6 minutes

Jump to

Key takeaways

  • Scroll handlers are expensive
  • The Intersection Observer is a great alternative
  • Besides increasing performance, using Intersection Observer can result in cleaner code

Introduction

Imagine you have a section on your page which should be hidden initially, and then fade in once the section is within your viewport. We won't get into detail on how to achieve the effect itself, but rather go through it's trigger, in this case the user's input which should conditionally trigger the effect.

Suppose you have a function that checks if the target section is within the viewport, and if so appends a CSS class ".visible". The content of the functions are not that important here - the examples are here to visualize what we want to accomplish.

Basic markup:

<div id="animate-me">
  <p>This will appear when you scrolled far enough on the page.</p>
</div>

Basic CSS:

#animate-me {
  opacity: 0;
  transition: 1s;
}

#animate-me.visible {
  opacity: 1;
}

And our logic:

const section = document.getElementById('animate-me');

const isInViewport = (element) => {
  // Pseudo code below, do your magic!

  // Get distance between element and top of viewport
  const elementDistance = element.getBoundingClientRect().y;

  // Determine viewport height
  const viewportHeight = window.innerHeight;

  return elementDistance < viewportHeight;
};

const showSection = (e) => {
  // Apply 'visible' class only when section is in viewport,
  // and only if the section doesn't already have that class
  if (isInViewport(section) && section.className.indexOf('visible') !== -1) {
    section.classList.add('visible');
  }
  else {
    section.classList.remove('visible');
  }
};

Now, to actually make anything happen on the page, we need something to trigger showSection().

You reach for the well known scroll event:

window.addEventListener('scroll', showSection);

Stop right there and reflect on the line above. What's wrong with it?

There are a couple of things going on here that is undesirable for our implementation above.

Unnecessary function calls

The most apparent issue is the fact that this will trigger showSection on every single scroll tick - it will trigger several times before you reach the target section, while it's in viewport, and after you've scrolled past the target section.

Sure, we do have an if-statement to block the most expensive part of the function (i.e. we return immediately if we should not toggle a CSS class), but this still puts unnecessary client side pressure with a condition that will be checked an awful lot during your browsing session. Not to mention this stacks up when you need more scroll handlers.

Alternatives

For our scenario above, we can replace our scroll handler with the Intersection Observer API.

Intersection Observer

The Intersection Observer is basically a way to keep track if and when an element is in the viewport, and attach a function that should be triggered at that point. You can also set specific thresholds if you want something to trigger when a percentage of your element is in the viewport.

You can also check element intersection in relation to another element rather than using the viewport as a reference, if you want.

The biggest difference compared to a scroll handler is the fact that this API offloads the cumbersome logic of checking element visibility off the main thread, and your callback will only run when the element has entered or left the viewport.

This means a lot less unnecessary function calls, much less stress on your browser, and happier end users.

Intersection Observer example

Let's jump right in to the fun part: the code.

This is the constructor signature for the Intersection Observer:

The callback triggers for each element it observes, once they "intersect" i.e. once they enter or leave the viewport (or other element, depending on how you use it).

var observer = new IntersectionObserver(callback[, options]);

The options object looks like this:

{
  root: [element, document object, or null], // null == top-level document viewport
  rootMargin: '0px', // Optional offset to the root's bounding box
  threshold: 0.0 // 0.0 - 1.0, where 1 means that 100% of the element has to be in view to trigger our callback
}

How would our example above look if we used the Intersection Observer API instead?

const section = document.getElementById('animate-me');

// Remember the "isInViewport" function? That is no longer need here

const toggleSection = (isIntersecting) => {
  // Apply 'visible' class only when section is in viewport,
  if (isIntersecting) {
    section.classList.add('visible');
  }
  else {
    section.classList.remove('visible');
  }
};

const observer = new IntersectionObserver(
  ([entry]) => toggleSection(entry.isIntersecting),
  {
    root: null,
    rootMargin: '0px',
    threshold: 0.0,
  }
);

observer.observe(section);

That's all we need, we could even refactor out a function entirely. Simple and clean.

If you want to learn more, you can check out a more in-depth documentation of the Intersection Observer on MDN.