Sticky Stacky Peeking Navigation Bars

At the time of writing I work at store.google.com. One of our design choices has been for the navigation to scroll with you. As the user scrolls down, one could have the navigation bars stack up. This pattern could fill the screen. Instead, this alternative design only shows the bottom most bar. As the user scrolls up, the navigation bars from above peek out from anywhere on the page.

This is a common design pattern. As I have fixed bugs with store.google.com's navigation bar, I have reconsidered how complex the setup is. Here, I present a simpler, more general, and scalable way to support such a system.

At its core, this system is not complex. The markup is basic, as is the styles. The javascript can be challenging to wrap your head around, since it has to keep up with the scroll state.

  1. A container that holds the height and position of the navigation bar on the page.
  2. The actual visible navigation bar that sticks when scrolling.
<div class="sticky-container">
  <div class="sticky-stacky">Sticky Nav Element</div>
</div>

For the container element, your styles are simple: a block element that holds the height of your navigation bar. Through some experimenting, I found a good way to smooth rendering, and simplify the javascript. We calculate the height and store it in a CSS variable.

For the navigation bar itself, the styles are still simple. You need a basic default state, and a fixed state. The default state uses position: relative. The fixed state moves it to position: fixed. The important piece keeps the navigation bars sticking in the right positions.

To keep them sticking in the right positions, we depend on two properties. The top property is the sum of the heights of previous navigation bars. Again, I am using a CSS variable for simplicity. The transform property uses another CSS variable to translate the nav bar up and down. This translation handles the peeking logic. The CSS variable we use here will be quite dynamic, and updated with JavaScript.

.sticky-container {
  height: var(--sticky-container-height);
  position: relative;
  z-index: 1000-2;

  .sticky-stacky {
    position: relative;
    z-index: 1000-1;

    &.fixed {
      position: fixed;
      z-index: 1000;
      top: var(--previous-heights);
      transform: translateY(var(--sticky-stacky-transform));
    }
  }

}

Breaking the algorithm into two pieces is the most organized. A class that controls all the sticky navigation bars on the page: StickyController. And a class that updates each sticky navigation bar: StickyStacky.

Read through the comments in the JavaScript below. The explanation makes the most sense to me in context.

/**
 * class StickyStacky element controller
 */
class StickyStacky {
  #previousHeights;
  #containerElement;
  stickyElement;
  top;
  height;
  isStuck;

  /*
    Simply get the peeking value stored in the CSS variable
  */
  #getCurrentTransform() {
    const valueString = document.documentElement.style.getPropertyValue('--sticky-stacky-transform');
    return Number(valueString.slice(0, -2)); // slice removes the 'px' from the value.
  }

  /*
    Recalculates the sticky-container-height CSS variable
  */
  update() {
    /* 
      Determine if the bar should be stuck by comparing the (scroll position 
      of page) + (how much the stack is peeking) to the top of the .sticky-container element.
    */
    this.isStuck = window.pageYOffset + (this.#previousHeights + this.#getCurrentTransform()) > this.top;

    // add/remove .fixed class based on stuck status.
    if (this.isStuck) {
      this.stickyElement.classList.add('fixed');
    } else {
      this.stickyElement.classList.remove('fixed');
    }

    // update top value, height value, and set the container height CSS variable.
    this.top = window.pageYOffset + this.#containerElement.getBoundingClientRect().top;
    this.height = this.stickyElement.offsetHeight;
    this.#containerElement.style.setProperty('--sticky-container-height', `${this.height}px`);
  }

  /*
    Take the `heights` parameter and set it as a CSS variable.
    Note: this value will be different for each StickyStack instance. 
      It's the sum of the heights of the StickyStacks earlier in the DOM.
  */
  setPreviousHeights(heights) {
    this.#previousHeights = heights;
    this.#containerElement.style.setProperty('--previous-heights', `${heights}px`);
  }

  constructor(containerElement) {
    this.#containerElement = containerElement;
    this.#previousHeights = 0;
    this.stickyElement = this.#containerElement.querySelector('.sticky-stacky');
    this.top = window.pageYOffset + this.#containerElement.getBoundingClientRect().top;
    this.height = this.stickyElement.offsetHeight;
    this.isStuck = false;
  }
}






/**
 * class StickyController global controller for StickyStacky elements
 */
class StickyController {
  #scrollHeight;
  #transformTop;
  #maxTransform;
  #stickyStacks;

  /*
    Simply filter the stuck StickyStacks (isStuck === true) from all StickyStacks
  */
  #getStuckStacks() {
    return this.#stickyStacks.filter((stickyStack) => stickyStack.isStuck);
  }

  /*
    Take the StickyStacks that are stuck (isStuck === true), then
    sort them by visual order on the page. Finally, loop over
    them to apply the shadows appropriately, and set the previous 
    height for each stuck stack.
  */
  #calculateMaxTransform() {
    let height = 0;
    const stuckStacks = this.#getStuckStacks()
      .sort((stackA, stackB) => (stackA.top < stackB.top ? -1 : 1));
    
    stuckStacks.forEach((stuckStack, index) => {
      if (index === stuckStacks.length - 1) { 
        // last stuck element gets the shadow
        this.#maxTransform = 0 - height; // set global variable. Must be a negative number.
        // stuckStack.stickyElement.classList.add('shadow');
      } else {
        // other stuck elements lose the shadow
        // stuckStack.stickyElement.classList.remove('shadow');
      }
      // accumulate heights and set them, just like in recalculateHeights()
      stuckStack.setPreviousHeights(height);
      height += stuckStack.height;
    });
  }

  /*
    Loop through the StickyStacks, accumulate the heights of each
    StickyStack and pass those previous height sums into the StickyStacks
  */
  #recalculateHeights() {
    let height = 0;
    this.#stickyStacks.forEach((stickyStack) => {
      stickyStack.update();
      stickyStack.setPreviousHeights(height);
      height += stickyStack.height;
    });
  }

  /*
    [Critical function] Calculates the scroll directin and adjusts
    the sticky stack peeking depth.
  */
  #update() {
    this.#calculateMaxTransform(); // sets maxTransform value.

    // Don't make any changes if the scroll depth hasn't changed.
    if (this.#scrollHeight !== window.pageYOffset) { 
      // determine the scroll depth difference
      const diff = this.#scrollHeight - window.pageYOffset; 
      // set global variable value for next time. Effectively caching the current value for later.
      this.#scrollHeight = window.pageYOffset; 
      // calculate the peeking depth. It cannot be greater than zero or less than the maxTransform
      this.#transformTop = Math.max(Math.min(this.#transformTop + diff, 0), this.#maxTransform);
      // set the peeking depth in the CSS variable
      document.documentElement.style.setProperty('--sticky-stacky-transform', `${this.#transformTop}px`);
    }

    this.#recalculateHeights(); // update the StickyStack height and previousHeights again
  }

  constructor(containerNodeList) {
    /* 
      Since the querySelectorAll function returns NodeLists,
      convert this to an Array so we can use .map and .forEach on it.
    */
    const containerArray = Array.from(containerNodeList);
    this.#scrollHeight = 0;
    this.#transformTop = 0;
    this.#maxTransform = 0;

    /*
      Sort the sticky stack elements in visual order from top to bottom.
    */
    this.#stickyStacks = containerArray
      .map((containerElement) => new StickyStacky(containerElement))
      .sort((stackA, stackB) => (stackA.top < stackB.top ? -1 : 1));

    /*
      Set incrementally higher z-index values to ensure the 
      shadows cascade without overlapping
    */
    this.#stickyStacks.forEach((stack, index) => {
      stack.stickyElement.style.zIndex = 10000 + index;
    });

    /*
      Update _AFTER_ the scroll event fires. I tried using other kinds of run loops,
      but this one performs the best without odd delayed overlaps.
      Do not debounce.
    */
    window.addEventListener('scroll', this.#update.bind(this));

    /*
      Running this twice at the beginning with these spaces seems to work well.
    */
    setTimeout(this.#update.bind(this), 0);
    setTimeout(this.#update.bind(this), 100);
  }
}





/*
  Module initializer function.
*/
module.exports.init = () => {
  /* get all of the .sticky-container elements on the page */
  const stickyContainers = document.querySelectorAll('.sticky-container');

  /*
    Instantiating a StickyController class with the stickyContainers
    triggers the calculation and update of all sticky stacky elements
  */
  new StickyController(stickyContainers);
};

I hope this has been a helpful and informative way to combine JavaScript, HTML, and CSS in a robust and scalable way. The patterns I use in this solution are more valuable than the solution itself.