How to build a collapsible sticky header with intersection observer

Intro

Intersection observer is a great tool that can simplify your life when it comes to implementing viewport-based UIs.

Every web developer should include intersection observer as part of their everyday toolkit. It is a great alternative to some of the traditional methods that relies on the scroll event, which can be very expensive and hard to maintain.

Today I'll show you how to build a collapsible sticky header for your web page.

What is an intersection observer?

Like the name states, an intersection observer is an observer that observes intersections between DOM elements. You set a element as a watcher, (MDN calls it a root), to watch a target element. When the target is going to intersect with the root, you invoke a callback. You can customized the intersection settings and the root via a options object.

Implementing a collapsible sticky header

First, we are going to create a simple html with page with a header and some sections, along with some simple css for visualization purposes.

html

<nav id="header" class="collapsible-header">
Header
</nav>
<main id="content">
<section class="my-section">
Section 1
</section>
<section class="my-section">
Section 2
</section>
Section 3
</section>
</main>

We create the sticky header by simply using position: sticky. For more advanced use cases, you can try position: fixed.

css

body {
padding: 0;
margin: 0;
}
.hide {
display: none;
}
.show {
display: block;
}
.collapsible-header {
position: sticky;
background-color: cyan;
width: 100%;
top: 0;
}
#content {
padding: 20px;
}
.my-section {
background-color: gray;
height: 500px;
margin-bottom: 20px;
}
#sentinel {
height: 1px;
background-color: red;
}

Let's say we want our header to be initially hidden until it hits the first <section>. By default, the root is set to document, so what we want to do is set up a target on the first <section> to observe when the top of the document, the viewport, intersects our target.

Now you might be tempted to set the target directly as the first <section>. This is a good intuition, but what ends up happening is that the intersection point becomes at the end of your first <section>, so we will not be showing a sticky header until you scroll past the height of the entire first <section>. Yikes

Instead, what we do is we create a new DOM element that sits right above the first <section>. People like to call this the sentinel. The sentinel does not need any height or width, you can think of it as a visually hidden element. Now, this works because the start and the end of the sentinel is basically the same and it sits right above the start of our first <section>.

So our new html will look like

<nav id="header" class="collapsible-header">
Header
</nav>
<main id="content">
<div id="sentinel"></div> <!-- Added sentinel here -->
<section class="my-section">
Section 1
</section>
<section class="my-section">
Section 2
</section>
<section class="my-section">
Section 3
</section>
</main>

Now for the fun part, we want to toggle the header to either show or hide based on the information from our intersection observer.

For each entry in the callback of the intersection observer, we get a isIntersecting boolean, which is pretty neat.

Now here is a tricky part, you might be tempted to show the header when isIntersecting is true.

In our case, isIntersecting=true means that the sentinel is visibly inside our viewport. This means that initially, isIntersecting is true because our sentinel element is inside the viewport.

What we actually want to do is actually check for isIntersecting=false because that means our sentinel is outside of our viewport. This also means that we have begun to scroll past the top of our first <section>, so we should show the header now.

Here's what the code looks like

const sentinel = document.getElementById("sentinel");
const header = document.getElementById("header");
// Optional, these are the default options anyways
const options = {
root: document,
rootMargin: "0px",
threshold: 0,
}
const callback = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
header.classList.remove('show');
header.classList.add('hide');
} else {
header.classList.remove('hide');
header.classList.add('show');
}
});
}
observer = new IntersectionObserver(callback, options);
observer.observe(sentinel);

Here's the entire code in jsfiddle for you to play with.

Scroll might be a bit messed up, so you might need to run this in jsfiddle. Enjoy!

```