Modern Navigation Menus with CSS position: sticky and IntersectionObservers

Published

New tools are becoming available every day in ES6 (or however high babel compiles these days..). They do cool things like sticky menus, animated scrolling, and firing scroll event events in a performant, asychronous fashion. I recently added a “Table of Contents” type menu to my blog, and used 3 of these new tools to build it:

  1. position: sticky (CSS), allowing us to create “sticky” navigation menus without a slew of JavaScript and extraneous markup to the DOM.
  2. Intersection Observer API (JavaScript), which despite its quirks, can be used to effectively monitor and respond to scroll position in web pages.
  3. The scrollIntoView() function (JavaScript), allows us to automatically scroll the document to a particular element, without having to do a lot of arithmetic 🙂

My blog now features a sticky top navigation header, and a sticky left aside, which makes up the Table of Contents. You guessed it, CSS position: sticky rule for the menus, IntersectionObservers for the scroll events, and animated scroll from the scrollIntoView() function.

Will CSS’s “position: sticky” finally Stick?

The position: sticky CSS rule has struggled with adoption since its first mentions in early 20121. Luckily, it has finally gained enough support in browsers to make it a viable tool for web developers.

Can I Use once-event-listener? Data on support for the once-event-listener feature across the major browsers from caniuse.com.

Traditionally a Sticky Menus required using JavaScript frameworks like Sticky Bits, or ScrollMagic. These frameworks were powerful, but were often overkill and added serious mark up to the DOM (wrapper divs, extra CSS classes, etc.), opening up the possibility of compatibility issues with other libraries.

Position: sticky allows developers to create Sticky Menus with pure CSS; no JavaScript libraries or event handlers required. While being easier for developers, scroll performance can improve on user’s devices as the sticky positioning is now handled by the GPU 2.

One thing to be aware of is the element will become sticky relative to it’s parent element, which is not necessarily the global browser viewport if added to a nested DOM element. See this very informative post for more of the gotchas associated with position: sticky.

Observing the IntersectionObserver API

Another emerging technology is JavaScript’s IntersectionObserver API, which allows developers to “observe” DOM elements, and respond to the JavaScript events that fire when the elements crosses the browser’s viewport; this supports things like triggering CSS animations, and updating navigation menus based on scroll position.

For more information (and some visuals), check out the Smashing Magazine write-up.

The IntersectionObserver is a bit quirky however. If I wasn’t researching this article, I probably would have scrapped it’s use in my Sticky Menu. Here are a few of the gotchas ran into:

  • The Observer fires an event for all observed elements at page load, regardless of scroll position
  • Logic is required to determine if an element is intersecting the top or bottom of the viewport (example code below); the scroll direction must also be calculated3
  • Since the IntersectionObserver operates asynchronously, events for multiple elements can be combined and fire a single JavaScript event; event callback logic must be built to handle multiple element intersections in a single execution
  • Polyfills sometimes use different properties when populating the BoundingClientRect of an intersecting element

Regardless of its quirks, I believe the IntersectionObserver API is here to stay… and if not, we’ll polyfill!

Coming into View: JavaScript’s scrollIntoView() Function

In the past, if we wanted to scroll the document to a particular element, we had to manually find it’s vertical offset first. Thanks to JavaScript’s scrollIntoView() function, we’ll no longer be reliant on jQuery’s .offset() function, or that clunky BoundingClientRect Object when manipulating a site’s scroll position. The API is incredibly easy to use, and even supports a “smooth” scroll animation in some browsers.

The only gotcha I ran into with the scrollIntoView() function was the smooth scrolling option was not supported in Safari, meaning iPhone users do not see the nice scroll animation. There are polyfills out there however, that bring smooth scrolling to all browsers.

CSS position:sticky In Action

As stated above, the position: sticky rule makes elements stick to the top (or bottom) of their parent container. In my menus, the sticky rule is accompanied by a “top” rule that defines how far down the user must scroll down before the element becomes “sticky”. Conversely, here is an example of someone using a “bottom” rule to create a footer that sticks to the bottom of the viewport.

#toc-left-aside{
    position: -webkit-sticky;
    position: sticky;
    top: 66px;
    width: 264px;
    display: inline-block;
    vertical-align: top;
}

Notice there are two position rules applied here; the additional “position: -webkit-sticky” enables sticky positioning support in Safari (make sure to list it first).

Using the IntersectionObserver in Navigation Menus

Configuring the Observer object is pretty straight forward; intersection thresholds can be tweaked, and a callback function (set to handleObserverIntersection() below) is designated for execution whenever an observed elements intersects the viewport.

// define observer options
const options = {
  root: null, // relative to document viewport 
  rootMargin: '-2px', // margin around root. Values are similar to css property. Unitless values not allowed
  threshold: 1.0 // visible amount of item shown in relation to root
};

// configure intersection observer to track scroll progress throughout document
const observer = new IntersectionObserver(handleObserverIntersection, options);

// observe each header tag in document with intersection observer
headerTags.forEach( (headerTag) => {      
  observer.observe(headerTag)
})

The Observer is configured to observe the position of header tags (e.g. h1s, h2s, etc.) through out my posts to determine which section of the document the user is viewing. Let’s take a look at the callback function:

handleObserverIntersection(elements) {
  
  // current index must be memoized or tracked outside of function for comparison
  let localActiveIndex = this.activeIndex
    
  // track which elements register above or below the document's current position
  const aboveIndeces = []
  const belowIndeces = []

  // loop through each intersection element
  //  due to the asychronous nature of observers, callbacks must be designed to handle 1 or many intersecting elements
  elements.forEach(element => {
    
    // detect if intersecting element is above the browser viewport; include cross browser logic
    const boundingClientRectY = (typeof element.boundingClientRect.y !== 'undefined') ? element.boundingClientRect.y : element.boundingClientRect.top
    const rootBoundsY = (typeof element.rootBounds.y !== 'undefined') ? element.rootBounds.y : element.rootBounds.top
    const isAbove = boundingClientRectY < rootBoundsY 

    // get index of intersecting element from DOM attribute 
    const intersectingElemIdx = parseInt(element.target.getAttribute('data-toc-idx')) 

    // record index as either above or below current index 
    if (isAbove) aboveIndeces.push(intersectingElemIdx) 
    else belowIndeces.push(intersectingElemIdx) 
  }) 

  // determine min and max fired indeces values (support for multiple elements firing at once) 
  const minIndex = Math.min.apply(Math, belowIndeces) 
  const maxIndex = Math.max.apply(Math, aboveIndeces) 

  // determine how to adjust localActiveIndex based on scroll direction 
  if (aboveIndeces.length > 0) {
    // scrolling down - set to max of fired indeces
    localActiveIndex = maxIndex
  } else if (belowIndeces.length > 0 && minIndex <= this.activeIndex) { 
    // scrolling up - set to minimum of fired indeces 
    localActiveIndex = (minIndex - 1 >= 0) ? minIndex - 1 : 0
  }

  // render new index to DOM (if required)
  if (localActiveIndex != this.activeIndex){
    this.activeIndex = localActiveIndex
    this.render()
  }

}

As you can see, the callback function is the real meat and potatoes of the IntersectionObserver logic. Hopefully once we dissect it, the pieces will make more sense.

Three observations to keep in mind (no pun intended):

  • The callback function must be written to support multiple elements intersecting at the same time; notice the the loop on lines 12-26, which iterates over each of the elements and tallies the highest and lowest elements
  • Lines 15-17 calculate if the intersecting element is above the viewport; unfortunately developers must differentiate between top and bottom intersection, and calculate scroll direction on their own (see lines 32-39 for an example of calculating scroll direction)
  • In this example, each element has an index integer added as a DOM attribute; this index is read on line 20, and is used to compare against the activeIndex to tell if the user is viewing a different section (in which case the render function is called on line 44)

Closing Observations

All three of these tools used to require custom JavaScript, but are now becoming natively supported by browsers, which is always an exciting prospect for web developers. While each of these technologies have their quirks, once understood the difficulties can be navigated. Browser support is not perfect, however polyfills exist and work well in my experience.

CSS position: sticky and the JavaScript scrollIntoView() were very straightforward to introduce. While the IntersectionObserver was fun to play with, it required much more effort to fully control. Given its complexity, I would recommend against its use for precise things like navigation menus. Hopefully someone will create a library to help simplify this powerful but complex API.

References

  1. Stick your landings! position: sticky lands in WebKit
  2. position: sticky is Amazing
  3. How do I know the IntersectionObserver scroll direction?

Subscribe by Email

Enter your email address below to be notified about updates and new posts.


Comments

Loading comments..

No responses yet

Leave a Reply

Your email address will not be published. Required fields are marked *