An Idiosyncratic Blog

Sticky Header using Intersection Observer API

by 逸 韩 on UnsplashPhoto by 逸 韩 on Unsplash

Intro

I recently added a Table of Contents component to my blog page to make navigation easier for long articles. The initial design was a simple static list which sits at top of the page before the post content.

Table of Contents Mark One

This initial implementation was okay-ish, but it was lacking a good UX.

  • The Table of Contents component was always at the top of the page, so if I wanted to skip to a section, I needed to scroll all the way to the top to the ToC component and click on a section. For long articles, this already became a chore
  • There was no way to identify what section I am currently reading. I had to scroll up till I reached the header.
  • For articles with multiple section header, the ToC component would simply take up space at the top of the page. I had to scroll down to reach to the actual body. A lot of wasted space.

A stroke of inspiration

I was browsing the Google Developers Blog for a potential solution for my use case, and I observed the Table of Contents on the side. It was highlighting the current visible section on the page as I scrolled the page.

Sync Table of Contents with Scroll

I had to implement this. But I was still looking for a good UI layout for the Table of Contents component that could solve all my UX problems mentioned above.


I was browsing the Gatsby GitHub repo for a potential plugin to solve my problem (since Gatsby ecosystem seems to have a plugin for everything), when the new README section caught my eye.

image-20210513011053739

The README top section became a sticky header on scroll, and a neat little popup listed out all the section headings when clicking on the hamburger menu icon. I really liked this UX. I 'got inspired' from it and decided to build one for my blog. 😅

Table of Contents v2

I needed 3 things in the navigation bar:

  • An icon, which when clicked, opens the sections headings in a panel below the header,
  • A dynamic title, which shows the heading of the current section of the page which is visible on the viewport.
  • A button to navigate to the top of the page.

This is the newly redesigned Table of Content component (you would be able to see it in action by the time this article is published).

Now I can

  • Access Table of Contents from anywhere on the page,
  • see what section I am on when scrolling the page (More on this later),
  • jump to any section by selecting the header from the Table of Contents component,
  • jump to the top of the page by clicking the up arrow icon in the corner of the navigation bar.

Update Nov 2021

I've moved this site to Next.js and have upgraded the Table of Contents to be more streamlined with the sites overall design. The intersection observer logic however, remains the same.

Figuring out who is active?

If you observe the header in action, whenever the page is scrolled and a section header reaches ~30% of the top of the page, the title in the navigation bar gets updated. This works on both forward scroll and backward scroll. I used the IntersectionObserver API to achieve this feature.1

Although, adding a scrollListener event and then calculating the position would also work, that would involve event handlers and loops calling methods like Element.getBoundingClientRect() to build the needed data for every element. Since all this code runs on the main thread, even one of these can cause performance problems.

About the Intersection Observer API, straight from the MDN Docs,

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.

So now I had to do the following in order to achieve the final result:

  • Detect when a section header is visible in the viewport
  • Detect when a section header reaches close to ~30% of the top of the viewport
  • Update the Navigation header with the section header
    • On forward scroll, display the current section header
    • On backward scroll, display the previous section header

Working with Intersection Observer API

The Intersection Observer API lets us register a callback function that is executed whenever an element we wish to monitor enters or exits another element (or the viewport), or when the amount by which the two intersect changes by a requested amount. This way, sites no longer need to do anything on the main thread to watch for this kind of element intersection, and the browser is free to optimize the management of intersections as it sees fit.

One thing the Intersection Observer API can't tell you: the exact number of pixels that overlap or specifically which ones they are; however, it covers the much more common use case of "If they intersect by somewhere around N%, I need to do something."

Understanding Intersection Observer API

We can create the intersection observer by calling its constructor and passing it a callback function to be run whenever a threshold is crossed in one direction or the other:

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0,
}

let observer = new IntersectionObserver(callback, options)

The options control the circumstances under which the callback is invoked.

  • root: The element that is used as the viewport for checking visibility of the target. Must be the ancestor of the target. Defaults to the browser viewport if not specified or if null.
  • rootMargin: Margin around the root. Can have values similar to the CSS margin property, e.g ."10px 20px 30px 40px" (top, right, bottom, left). The values can be percentages. This set of values serves to grow or shrink each side of the root element's bounding box before computing intersections. Defaults to all zeros.
  • threshold: Either a single number or an array of numbers which indicate at what percentage of the target's visibility the observer's callback should be executed. If you only want to detect when visibility passes the 50% mark, you can use a value of 0.5. If you want the callback to run every time visibility passes another 25%, you would specify the array [0, 0.25, 0.5, 0.75, 1]. The default is 0 (meaning as soon as even one pixel is visible, the callback will be run). A value of 1.0 means that the threshold isn't considered passed until every pixel is visible.

In my case, the root is the default browser viewport, and threshold = 1 because I wanted the entire heading to be visible. Playing around with the rootMargin property, I settled on rootMargin: '0px 0px -70% 0px',.

The -70% means that if a header is almost at 70% of the page, this doesn't count as being "visible". If a heading near the bottom of the page, you've probably not actually reached that section yet.

Observing Headings when scrolling

All my blog posts are written in markdown, and parsed using gatsby-transformer-remark . So I need to wait till the Markdown is rendered, and then observe the section headings.

const headings = document.querySelectorAll('h1[id], h2[id]')
const observer = new IntersectionObserver(observerCallback, options)
// Track all sections that have an `id` applied
headings.forEach((section) => {
  observer.observe(section)
})

I just want to observe h1 and h2 headers, because that's what is being displayed in the Table of Contents component as well. Using, querySelectorAll I get reference of all h1 and h2 headers, and then observe each section heading.

For the observerCallback, I wrote conditions to check the following:

  • The Table of Contents component is in the DOM. (For articles with 0 section headers, it's not shown, so this check is needed)
  • Detect when the section header is colliding with the Navigation Header, either when scrolling forward, or reverse.
;(entries) => {
  entries.forEach((entry) => {
    const currentY = entry.boundingClientRect.y
    const id = entry.target.getAttribute('id')
    const tocWrapper = document.getElementById('toc-wrapper')
    if (tocWrapper) {
      const decision = {
        id, // Current Target
        currentIndex: headingsArray.findIndex((heading) => heading.getAttribute('id') === id), // Get the index of the current target from the Headers list
        isIntersecting: entry.isIntersecting, // true | false
        currentRatio: entry.intersectionRatio, // 0 - 1
        aboveToc: currentY < tocWrapper.getBoundingClientRect().y,
        belowToc: !(currentY < tocWrapper.getBoundingClientRect().y),
      }
      if (decision.isIntersecting && decision.currentRatio === 1) {
        // Header at 30% from the top, update to current header
      } else if (
        !decision.isIntersecting &&
        decision.currentRatio < 1 &&
        decision.currentRatio > 0 &&
        decision.belowToc
      ) {
        // Previous Section Content is now visible, update to previous header
      }
    }
  })
}

To identify the if the section header is above the Navigation Header or below it, I check if the boundingClientRect.y of the section header is less/greater than boundingClientRect.y of the Navigation Header (tocWrapper).

aboveToc: currentY < tocWrapper.getBoundingClientRect().y,
belowToc: !(currentY < tocWrapper.getBoundingClientRect().y),

From here, the rest of the logic pretty straightforward. I get the id of the entry which is currently visible, and broadcast in a callback handler to the Table of Contents component. And because we are using React, the entire Intersection Observer logic is encapsulated in a hook which is called like so:

useIntersectionObserver(
  [html], // The HTML content
  'h1[id], h2[id]', // Selectors to observe
  setCurrentHeading // Callback Function when collision happens
)

And the Table of Contents component now syncs with page scroll and updates the Heading dynamically.

Here is the complete useIntersectionObserver hook:

export const useIntersectionObserver = (deps, selectors, handler) => {
  useEffect(() => {
    // Get the list of all headings
    const headings = document.querySelectorAll(selectors)
    const headingsArray = Array.from(headings)
    const tocWrapper = document.getElementById('toc-wrapper')
    // Create the IntersectionObserver API
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          const currentY = entry.boundingClientRect.y
          const id = entry.target.getAttribute('id')
          if (tocWrapper) {
            // Create a decision object
            const decision = {
              id,
              currentIndex: headingsArray.findIndex((heading) => heading.getAttribute('id') === id),
              isIntersecting: entry.isIntersecting,
              currentRatio: entry.intersectionRatio,
              aboveToc: currentY < tocWrapper.getBoundingClientRect().y,
              belowToc: !(currentY < tocWrapper.getBoundingClientRect().y),
            }
            if (decision.isIntersecting && decision.currentRatio === 1) {
              // Header at 30% from the top, update to current header
              // Notify the ToC Component
              handler(decision.id)
            } else if (
              !decision.isIntersecting &&
              decision.currentRatio < 1 &&
              decision.currentRatio > 0 &&
              decision.belowToc
            ) {
              // Previous Section Content is now visible, update to previous header
              const currentVisible = headingsArray[decision.currentIndex - 1]?.getAttribute('id')
              // If currentVisible == undefined, ToC component will fallback to show 'Table of Contents'
              handler(currentVisible)
            }
          }
        })
      },
      {
        threshold: 1,
        rootMargin: '0px 0px -70% 0px',
      }
    )
    // Observe all the Section Headers
    headings.forEach((section) => {
      observer.observe(section)
    })
    // Cleanup
    return () => {
      observer.disconnect()
    }
  }, deps) // Dependency here is the post content.
}

Not all sunshine and rainbows

While this implementation works well, there are still a couple of bugs I need to fix.

  • When landing on a page with anchor link (#) in the URL, the Navigation Header doesn't get notified which section header is currently intersecting and colliding. User has to scroll the page a bit for the useIntersectionObserver to run. This is more of an oversight in my code, the Intersection Observer runs initially and reports what section headers are currently intersecting and colliding. I just haven't figured out a logic to check which section header gets displayed on the Navigation Header.
  • When reloading the page, if the Gatsby scroll restoration does not kick-in, then the Navigation Header doesn't get notified which section header is currently active. This is again similar to the above issue.
  • Clicking on the Top icon in the Navigation Header does not update the heading. Again, this is more of an oversight and would probably be fixed when I get around to fixing point #1.
  • At the time of publishing this article, the API is still experimental and browser support looks like
Browser Support

Journey doesn't end here

So this is how I implemented a Table of Contents component with a Sticky Navigation header, which syncs with the page scroll and updates itself to show the current active section. There are still a couple of issues which I'd have to fix, but the current implementation does the job, for now...

Feel free to reach out in case you want to know more how I used Interesection Observer and the React components. Comment below if you find a fix for the page reload issue ;)

Until next time! ✌🏽

Footnotes

  1. An Explanation of how the intersection observer watches