Dan J Ford

A 'Scroll Up' fixed header

March 29, 2016 by Dan J Ford

Recently, I have found it particularly annoying (even on my own site) that when I'm scrolling down, there is a fixed header following me. So instead of using this approach, I thought that it would be much nicer if the fixed header would only appear when you scroll back up.

You may have already encountered this on websites, however, quite a lot of them just make the header quickly appear which, I think, creates quite a 'jarring effect'. The approach that I would like to happen is that as the user scrolls back up, the header comes down slowly at their scroll speed to meet them. For example:

Desired Effect
Desired Effect

Before we start

For this post, I will be writing my code in plain, vanilla JavaScript but using features from ES2015 such as const, let, template strings and arrow functions. At the end of the tutorial I will also post the code in the pre-ES2015 syntax in case you wanted to see it. If you would like to see this coded up in a library / framework such as jQuery, let me know!

Starting out

First things first, we will want to create the elements for our JS to interact with. For the sake of ease, I will be making an absolutely positioned header and a main container which adds padding to the top, this padding will be the height of the header. We will also want a fixed class that we can apply to the header when we want it to stick to the top.

.header {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  &.fixed {
    position: fixed;
  }  
}

.container {
  padding-top: 70px;
}
<header class="header">
  Top Navigation
</header>

<main class="container">
  <!-- Add some content in here -->
</main>

Note: If you wanted to make this more re-usable for reasons such as making it a library, you would probably make the header relative to start out with and then dynamically make it absolutely positioned with the necessary padding calculated afterwards.

Setting up the initial code

Now that we have the initial elements, we will need to create the function that is called when the scroll event fires. I like to create the function outside of the addEventListener as this allows the code to be more testable and re-usable. As well as this function, I will declare some additional variables and a helpful function which I think will be useful which I will explain in a second.

// Get the scrollTop of a passed element name
const getScrollTop = (el) => {
  return document.querySelector(el).scrollTop;
}

// Our header element
const header = document.querySelector('.header'),
   fixedClass = 'fixed';

// Get the initial scroll top of the body, and set isAbsolute to true.
let previousScroll = getScrollTop('body'),
    isAbsolute = true;

const scrollEvent = (e) => {
  // Where our scrollEvent code will go
};

// Add the function to the event listener
document.addEventListener('scroll', scrollEvent);

I will now explain why I have declared each of these variables and functions. If you already know why I have set it up like this, then continue on to the next section!

  • getScrollTop - As the comment indicates, I have created this function to be a quick method of retrieving the scrollTop of a passed elements name. I have done this as I feel I might need to get scrollTop multiple times, so this is just a way of minimising duplicated code.

  • header - In the above example, I am setting the header element to the const header as I plan on re-using it a lot within the scrollEvent, and having it outside of this function will be a more efficient placement for it, rather than needing to re-find the element each time scrollEvent is called.

  • fixedClass - The class that I want to give to the header when I make it's position fixed.

Note: If you were to include this code globally in a website where there isn't always a header, this will likely cause errors. In a case like this, you would most likely need to find and check the existence of the header each time in the scroll event.

  • previousScroll - This will be a variable that I use throughout scrollEvent, the reason I am getting the initial scrollTop of the body element rather than setting the variable to 0 is that, if the user is returning to this page, their previous scroll position might be cached. So in a scenario like this we would want to have the most recent scrollTop, rather than some arbitrary number.

  • isAbsolute - This is a Boolean attribute which I plan on using in the scrollEvent to detect whether or not the header is in an absolute state. I am using a boolean rather than calculating / checking the status each time again, simply for efficiency and ease.

Creating the scrollEvent function

I will separate creating this event into the three main sections of functionality that I want this header to have.

I want:

  1. The header to be placed above the viewport so that the user can scroll it into view
  2. I want the header to be fixed when it reaches the top
  3. I want the header to be released from the fixed position when I start scrolling down again
// Our scroll event code
const scrollEvent = (e) => {

  // Get the body's new scrollTop
  const newScroll = getScrollTop('body');

  // Calculate the headers position using it's offset and height
  const headerClientHeight = header.clientHeight,
        headerOffsetTop = header.offsetTop;


   // The main body of the code following the 3 requirements
   // If condition 1
   // else if condition 2
   // else if condition 3


   // Set the previousScroll to be the current scroll i.e. newScroll
   previousScroll = newScroll;

};

Placing the header above the viewport

Following the first requirement, I want the header to be placed above the viewport when I begin scrolling up. This will allow the header to ease into view at the user's rate of scrolling. To do this we need to check that:

  • newScroll < previousScroll, this indicates that the user is scrolling upwards
  • (headerClientHeight + headerOffsetTop) < newScroll, this makes it so that the header won't jump around if you were to, for example only scroll half way up the header's height
  • isAbsolute, we only want to position the header above the viewport if it is absolute i.e. not in the fixed position.

When the if statement is true, I can then set the header's stop style to be the header's height subtracted from the current scroll position i.e. newScroll - headerClientHeight.


const scrollEvent = (e) => {

  // Get the body's new scrollTop
  const newScroll = getScrollTop('body');

  // Calculate the headers position using it's offset and height
  const headerClientHeight = header.clientHeight,
        headerOffsetTop = header.offsetTop;


   // The main body of the code following the 3 requirements
  // Requirement 1
  if ( newScroll < previousScroll && (headerClientHeight + headerOffsetTop) < newScroll && isAbsolute ) {

    header.style.top = `${newScroll - headerClientHeight}px`;

  }


   // Set the previousScroll to be the current scroll i.e. newScroll
   previousScroll = newScroll;

};

Fixing the header

Following the second requirement, I want it so that, once the top of the header has reached the top of the viewport and we are scrolling upwards, I want it to get fixed.

To do this, we need to check that:

  • The newScroll position is either less or equal to the headers top offset i.e. headerOffsetTop
  • isAbsolute is true, otherwise we have already fixed the element and it doesn't need to be fixed.

Once these conditions evaluate to true, we can set the headers top position to be 0 and give it our fixedClass i.e. 'fixed'. We are setting the header's top style to be 0 as we have previously changed it when it was absolute, and this wouldn't be a good position for the element in a fixed state.


const scrollEvent = (e) => {

  // Get the body's new scrollTop
  const newScroll = getScrollTop('body');

  // Calculate the headers position using it's offset and height
  const headerClientHeight = header.clientHeight,
        headerOffsetTop = header.offsetTop;


   // The main body of the code following the 3 requirements
  if ( newScroll < previousScroll && (headerClientHeight + headerOffsetTop) < newScroll && isAbsolute ) {

    header.style.top = `${newScroll - headerClientHeight}px`;

  // Requirement 2
  } else if ( newScroll <= headerOffsetTop && isAbsolute ) {

    // We have reached the top of the header, so fix it!
    header.style.top = 0;
    header.classList.add( fixedClass );
    isAbsolute = false;

  } 

   // Set the previousScroll to be the current scroll i.e. newScroll
   previousScroll = newScroll;

};

'Releasing' the header

Finally, our third requirement is that, once we begin scrolling down again, we no longer want the header to be following us. This will give the effect that we are simply 'leaving the header behind'.

To make this work we will need to check that:

  • The newScroll is greater than previousScroll, meaning that we are scrolling down
  • !isAbsolute, meaning that the header is currently in the fixed position

Once these conditions evaluate to true, we will want to remove the fixed class, set the top to be the current scroll position and make isAbsolute true again.

const scrollEvent = (e) => {

  // Get the body's new scrollTop
  const newScroll = getScrollTop('body');

  // Calculate the headers position using it's offset and height
  const headerClientHeight = header.clientHeight,
        headerOffsetTop = header.offsetTop;


   // The main body of the code following the 3 requirements
  if ( newScroll < previousScroll && (headerClientHeight + headerOffsetTop) < newScroll && isAbsolute ) {

    header.style.top = `${newScroll - headerClientHeight}px`;

  } else if ( newScroll <= headerOffsetTop && isAbsolute ) {

    // We have reached the top of the header, so fix it!
    header.style.top = 0;
    header.classList.add( fixedClass );
    isAbsolute = false;

  // Requirement 3  
  }  else if ( newScroll > previousScroll  && !isAbsolute ) {

    // We are scrolling down again so add the top position to be the current scroll
    // and remove fixed. This gives the appearance that we are just leaving it there
    // as it slides out of view.
    header.style.top = `${newScroll}px`;
    header.classList.remove( fixedClass );
    isAbsolute = true;

  }

   // Set the previousScroll to be the current scroll i.e. newScroll
   previousScroll = newScroll;

};

Finished!

That should be everything for making this scroll feature work! If you look at my codepen, you can see what the end result looked like for me. If you look through the source code on codepen, you may notice that I've also added some bonus css to the header, this is in an attempt to make the scrolling effect smoother on mobile browsers.

Extras

If you wanted to have this code not in ES2015, here it is:

var getScrollTop = function getScrollTop(el) {
  return document.querySelector(el).scrollTop;
};

var header = document.querySelector('.header');

var previousScroll = getScrollTop('body'),
    isAbsolute = true;

var scrollEvent = function scrollEvent(e) {

    var newScroll = getScrollTop('body'),
        headerClientHeight = header.clientHeight,
        headerOffsetTop = header.offsetTop,
        fixedClass = 'fixed';

    if (newScroll < previousScroll && headerClientHeight + headerOffsetTop < newScroll && isAbsolute) {
        header.style.top = newScroll - headerClientHeight + 'px';
    } else if (newScroll <= headerOffsetTop && isAbsolute) {
        header.style.top = 0;
        header.classList.add(fixedClass);
        isAbsolute = false;
    } else if (newScroll > previousScroll && !isAbsolute) {
        header.style.top = newScroll + 'px';
        header.classList.remove(fixedClass);
        isAbsolute = true;
    }

    previousScroll = newScroll;
};

document.addEventListener('scroll', scrollEvent);
Like what I've written?