Sticky Headers with Pure JavaScript

Sticky headers with Javascript

At the end of the last week, I had another exciting task–implementing sticky headers within a scrollable element. Headers had to change dynamically when a user scrolls over the data back and forth. All these bits of magic were required to work within and Angular app so I had to wrap the login in an Angular JS directive. However, in this article, we will not touch Angular and go through an example with pure JavaScript, without JqLite, jQuery or any other library.

So, I decided to take it as a challenge, think over the logic by myself, and didn’t google this question at all. At the end, when the work was done and tested, I peered over the web and found a few articles like this and this, which have a similar approach and make use of the jQuery library. We might not have jQuery in our project though and it would be unwise to include an entire library for such a simple task. To make it even more interesting, I will be using vanilla JavaScript in the following examples. Before we get started, please take a look at this demo to have a good idea of what we will be doing.

HTML structure

<div id="container" class="container">
    <div class="header-floating">This is a floating header</div>

    <p class="message">Test record 0...</p>
    <p class="message">Test record 0...</p>
    ...
    <p class="message">Test record 0...</p>
    <p class="message">Test record 0...</p>

    <h2 class="header">Header 1</h2>

    <p class="message">Test record 1...</p>
    <p class="message">Test record 1...</p>
      ...
    <p class="message">Test record 1...</p>
    <p class="message">Test record 1...</p>

    <h2 class="header">Header 2</h2>

    <p class="message">Test record 2...</p>
    <p class="message">Test record 2...</p>
    ...
    <p class="message">Test record 2...</p>
    <p class="message">Test record 2...</p>

    <h2 class="header">Header 3</h2>

    <p class="message">Test record 3...</p>
    <p class="message">Test record 3...</p>
     ...
    <p class="message">Test record 3...</p>
    <p class="message">Test record 3...</p>

    <h2 class="header">Header 4</h2>

    <p class="message">Test record 4...</p>
    <p class="message">Test record 4...</p>
     ...
    <p class="message">Test record 4...</p>
    <p class="message">Test record 4...</p>

    <h2 class="header">Header 5</h2>

    <p class="message">Test record 5...</p>
    <p class="message">Test record 5...</p>
     ...
    <p class="message">Test record 5...</p>
    <p class="message">Test record 5...</p>
</div>

Say, we have a scrollable DIV which has certain width and height. There are some blocks of text with headers inside. We will also add a floating header div which is empty at the beginning but is to be filled with child HTML elements soon.

On every container scrolling, we will run a series of tests, and change (or remove) a floating header depending on the scrolling position.

CSS stylesheet

body {
  font: 14px/16px Arial, Verdana;
  padding: 10px;
  background: #345a80;
}

.container {
  width: 550px;
  margin: 0 auto;
  background: #dddddd;
  position: relative;
  overflow-y: auto;
  height: 300px;
  padding: 10px;
}

.header, .header-floating {
  font-size: 24px;
  color: #345a80;
  font-weight: normal;
  padding: 10px 0;
  margin: 0;
}

.header-floating {
  background: rgba(255, 255, 255, 0.9);
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: none;
  padding-left: 10px;
  padding-right: 10px;
}

The main div block (.container) has 550px width and 350px height, is centered (margin: 0 auto;) and scrollable (overflow-y: auto). It also has relative positioning (position: relative;) thanking to which the floating header’s absolute position (position: absolute;) will work correctly.

The div of sticky header (.header-floating) is also initially hidden. We will hide or display it conditionally.

Let’s examine the JavaScript code

window.onload = function() {
    // Select floating element. There's only one such
    // element on the page, so we're using querySelector method
    // that returns only one element.
    var floatingTitle = document.querySelector('.header-floating');
    // Select all title elements. We have several such elements,
    // so we're using querySelectorAll method that returns all
    // elements available within a page in an array.
    var titles = document.querySelectorAll('.header');
    // Select a scrollable container.
    var scrollable = document.querySelector('.container');
    // Add a function to run on a scroll event.
    scrollable.onscroll = function() {
        changeFloatingHeaderOnScroll(floatingTitle, titles);
    };

    function changeFloatingHeaderOnScroll(floatingTitle, titles) {
        // Stop the execution if no titles found
        if (!titles.length) {
            return;
        }
        // Height of the scrollable element
        var elementHeight =  scrollable.offsetHeight;
        // Current scrolling position (is equal to 0 when
        // the scrolling is on top). Everything above is hidden behind viewport.
        var scrollTopPosition =  scrollable.scrollTop;
        // The last visible point at the bottom of the element.
        // Everything below is hidden behind viewport.
        var scrollBottomPosition = scrollTopPosition + elementHeight;
        // Elements for headers above and below current scrolling screen.
        // We'll make use of them in case of no visible headers on current screen.

        // Declare a variable for the last element
        // above the current viewport position.
        var lastAbove;
        // Determine if the iterated title is the last title in the list.
        // This property will be used in the last test.
        var titleIsLast =  titles.length === (i + 1);
        // Indicator if fixed title is set.
        // If it is, the iteration will be stopped.
        var fixedTitleIsSet = false;
        // Iterating over the header elements and making tests
        // on whether the current element should be sticky.
        // If a sticky element is found, the further iterations will be skipped.
        for (var i = 0, len = titles.length; i < len; i++) {
            if (!fixedTitleIsSet) {
                // Current title element
                var title = titles[i];
                // Determine if the title is visible (within the viewport)
                // at the moment of scrolling
                var titleIsVisible = (title.offsetTop + title.offsetHeight) >= scrollTopPosition
                    && title.offsetTop <= scrollBottomPosition;
                // Determine the last above title (above the scrolling point).
                // Will be overridden with every iteration
                // of the elements above the viewport.
                if (title.offsetTop <= scrollTopPosition) {
                    lastAbove = title;
                }
                // Set top value as the current scroll position
                floatingTitle.style.top = scrollTopPosition + 'px';
                // If we are at the top of the scrollable element,
                // hide the floating title.
                if (!scrollTopPosition) {
                    floatingTitle.style.display = 'none';
                    return;
                }
                // Now based on the determined title data, check different variations

                // If the title visible and is the first title, no floating title is needed
                else if (titleIsVisible && i === 0) {
                    floatingTitle.style.display = 'none';
                    fixedTitleIsSet = true;
                }
                // If the title is visible and there's a hidden header above,
                // set the previous invisible header
                else if (titleIsVisible && i > 0) {
                    floatingTitle.style.display = 'block';
                    floatingTitle.innerHTML = lastAbove.innerHTML;
                    fixedTitleIsSet = true;
                }

                // If the title is the last element is not visible
                // somewhere below the screen), set the last element above.
                // If the title is above the screen, it'll the be determined
                // as the lastAbove anyway.
                if (!titleIsVisible && titleIsLast && lastAbove) {
                    floatingTitle.style.display = 'block';
                    floatingTitle.innerHTML = lastAbove.innerHTML;
                    fixedTitleIsSet = true;
                }

                else if (!titleIsVisible && lastAbove) {
                    floatingTitle.style.display = 'block';
                    floatingTitle.innerHTML = lastAbove.innerHTML;
                    // We don't stop the iteration here as there might be
                    // some other elements above the viewport that
                    // are closer to the current viewport position.
                    fixedTitleIsSet = false;
                }
            }
        }
    }
};

I tried to explain what’s going on in comments in a very detailed manner.

The most complex function of the script is attached to the page load event. It will be run only once at the moment when the DOM is loaded. It does the following:

  1. Selects the following elements from the DOM: scrollable container, floating title and existing titles. For selecting, we use document.querySelector and document.querySelectorAll. Query selector functions have already wide browser support but if you have jQuery available, use it for selecting instead.
  2. Attaches the changeFloatingHeaderOnScroll function to every onscroll event of the scrollable container. It means that every time we scroll the container, the changeFloatingHeaderOnScroll will be executed.
  3. Defines the changeFloatingHeaderOnScroll function. It does actually a lot:
  • Iterates over headers and determines whether they are visible (within browser’s viewport);
  • Also stores last element above the viewport;
  • Sets the top position of the fixed element equal to the current scrolling position–this guarantees that the fixed element is always visible;
  • Runs a series of tests using the data of current scrolling point and placement of headers to determine which header has to be sticky;
  • Make the sticky-header element visible by adding the display:block; style to it;
  • Hide the sticky-header element by adding the display:none style to it if the scrolling point is on top (is equal to 0) or if the first title is visible.

Please read the comments within the JavaScript code for more detailed explanation.

Again, if you have jQuery you can hide or show elements by adding or removing classes to them. It would allow you to attach some cool animations to make the user experience more smooth and friendly. You can also use plain JavaScript function for adding or removing a class–read how to write it here. This is a very basic example and you can improve it as much as you like.

Thanks for reading. Hope this articles has given you right directions.