Performant scroll-based styling with Intersection Observer

I love a well-done 'sticky' element - an element that scrolls with page, then locks to the browser window when it reaches a specific location. A common example of this is the header on many sites, as seen below. At first the element scrolls with the page, then becomes fixed when it reaches the top of the page.

It's common in these cases to apply different styling to the element when it becomes fixed. In the example above the background and text color are changed.

In the past I would have used an onScroll listener to check the position of the element, and if the element is at the top apply the necessary styles. This method, using an onScroll listener is extremely bad for performance - the callback is repeatedly called as the page is scrolled. For this reason it's common wrap the onScroll callback in a debounce to improve performance.

A more performant and modern way to accomplish this is to use the Intersection Observer API - which provides a method to asynchronously observe changes in the intersection of a two elements, or with the device's viewport. In other words, the Intersection Observer API allows us to call a function whenever one element, called the target, intersects either the device viewport or a specified element, called the root. The advantage of the Intersection Observer is the callback is called only when the intersection occurs - once when an element enters, and once when the element leaves the viewport.

In my case, I want to know when the header element is stuck at the top of the viewport. To do this I opted to used what's informally known as the  'sentinel method'. With this method, a 'sentinel' element (the target) is added as a sibling element to the element we intend to alter, the header. When the sentinel element leaves the viewport (the root), meaning the target and root do not intersect, we know the intended element (the header) is at the top of the page.

Let's dive deeper into this 'sentinel' method. Because the sentinel target element is located just before the header element in the HTML structure, the intersection (or lack of intersection) of the target with the root will indicate when the header element is at the top of the viewport. In other words, when the page is scrolled down the sentinel leaves the viewport (no intersection), indicating the header is at the top. When the page is scrolled up the sentinel enters the viewport (intersects) indicating the header is no longer at the top. The intersection of the sentinel with the viewport indicates when the header becomes stuck at the top and allows us to style it appropriately.

So how do we do this? It's actually really simple, here's the minimal code:

<div>
  Content above our sticky element
</div>
<div id="sentinel"></div>
<header id="header" style="position: sticky; top: 0;">
  Sticky header
</header>
<div>
  The rest of the page
</div>
const sentinelEl = document.getElementById('sentinel')
const headerEl = document.getElementById('header')
const stuckClass = 'stuck'

const handler = (entries) => {
  if (headerEl) {
    if (!entries[0].isIntersecting) {
      headerEl.classList.add(stuckClass)
    } else {
      headerEl.classList.remove(stuckClass)
    }
  }
}

const observer = new window.IntersectionObserver(handler)
observer.observe(sentinelEl)

The handler is a simple function to check if the target (the sentinel) is intersecting the root (the viewport). If the target is intersecting the root , this means the header is not stuck at the top and we should remove the stuckClass. If the target is not intersecting the root this means the header is stuck at the top and we should add the stuckClass.

The key lines here are:

const observer = new window.IntersectionObserver(handler)
observer.observe(sentinelEl)

The first line creates the IntersectionObserver by calling its constructor and passes a callback to be run whenever the intersection of the target and root occurs - both when it enters and leaves the viewport. The default root is the root element and its bounds are the viewport. The second line tells the observer what the target element to watch is.

The Intersection Observer will notify us when the #sentinel element scrolls out of the viewport and apply our styles (or a class, etc) to the header. The Intersection Observer will also tell us when the #sentinel is scrolled back into the viewport and remove our styles (or a class, etc) from the header.

Here it is in action:

Intersection Observer Example

And that's it. In my specific case I'm using React, but little is changed. I attach refs to the root and target, and register the IntersectionObserver in a useEffect hook, but that's the only difference.

Super happy with the result, and it's much simpler and performant than using an onScroll callback. The IntersectionObserver docs on MDN give several other great example of possible uses - like lazy loading of images, infinite scrolling, and more.

Comments