Sticky Header on Scroll with CSS, Javascript and RAF

Published on May 19 2016

Having a fixed navigation bar which is always visible to the viewer is a nice feature which gives quick access to the site content and search engine without the need to scroll back to the top of the page.

In this example the navigation bar starts off in flow and is only fixed when the viewer scrolls down the page. When scrolling back to the top the navigation bar is reset to it's original state.

The basic page framework.

The target is the header element, we want that to become fixed when the page has scrolled the banner element above it out of view.

<div id="banner" class="banner"><h1 class="logo">jonjohnston.co.uk</h1></div>
<div id="header" class="header"><nav></nav></div>
<div id="main" class="main"></main>

Create a CSS class for the sticky event.

This can be added to an external style sheet or placed inside style tags in the document head.

.sticky {position:fixed;top:0;}

The Javascript:

Target the elements we need to manipulate with javascript.

var header = document.getElementById('header');
var main = document.getElementById('main');

We then need to calculate the offset height of the header element, (distance between the top of the header and the very top of the document).

var headerOffset = header.offsetTop;

Running code inside a window scroll event can be extremely expensive, potentially firing many times per second depending on how fast the user scrolls. With RequestAnimationFrame (RAF) we give the browser control over the animated event.

window.requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame;

The window.scrollY (vertical scroll, distance travelled) is calculated by the browser and we can get that data in real time as the page is scrolled.

function onScroll() 
{
latestKnownScrollY = window.pageYOffset || window.scrollY || document.documentElement.scrollTop;
requestTick();
}

RequestAnimationFrame(call fn)

function requestTick() 
{
    if(!ticking) 
    {
        requestAnimationFrame(update);
    }
    ticking = true;
}

Set or Unset the CSS class conditions.

function update()
{       
    /** sticky header on scroll */
    switch(true)
    {
       case latestKnownScrollY > headerOffset && !sticky:
            sticky=true;
            main.style.paddingTop = header.clientHeight+'px';
            header.classList.add('sticky');
            break;
       case latestKnownScrollY < headerOffset && sticky:
            sticky=false;
            main.style.paddingTop = '';
            header.classList.remove('sticky');
            break;
    }
}

In addition we need to take into account the height of the fixed header element which is no longer in the document flow when position fixed is applied, thus leaving a space which forces the main content to jump the gap that has now appeared. To keep everything smooth and inline we can calculate the header height and add it as padding to the element below, in this case the main content element.

Set a document scroll event listener which calls the 'onScroll' function when the page is scrolled.

document.addEventListener('scroll',onScroll,false);

Example document: demo.html

please note this is for reference only and may need some additional code to make it work in all browsers and frameworks.

[selectable text]
<!DOCTYPE html>
<html><head lang="en"><meta charset="utf-8">
<title>Sticky Header Example</title>
<meta name="description" content="index page">
<meta name="author" content="jon">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
/* box-sizing for all elements */
* {margin:0;padding:0;border:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;}
body{}
/* some basic css for the demo page */
.banner,.header,.main{display:block;margin-bottom:0.3em;}
.banner{text-align:center;}
.header{width:100%;height:60px;background-color:black;}
.header{box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);}
.content{padding:1.3em;margin-bottom:500px;}
p,pre{margin-bottom:1.3em}
/* fixed positioning */
.sticky {position:fixed;top:0;}
</style>
</head><body>
<div id="banner" class="banner"><h1 class="logo">jonjohnston.co.uk</h1></div>
<div id="header" class="header"></div>
<div id="main" class="main">

<div class="content">
    <p>Sticky Header with CSS, Javascript and RequestAnimationFrame.</p>
    <h2>Javascript:</h2>
<pre><code>
'use strict';

<span style="color:green;">// Browser hardware accel</span>
window.requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame;

<span style="color:green;">// get / set global vars  </span>
var header = document.getElementById('header');
var main = document.getElementById('main');
var headerOffset = header.offsetTop;
var sticky = false;
var ticking = false;
var latestKnownScrollY = 0;

<span style="color:green;">// on scroll</span>
function onScroll() 
{
    latestKnownScrollY = window.pageYOffset || window.scrollY || document.documentElement.scrollTop;
    requestTick();
}
function requestTick() 
{
    if(!ticking) 
    {
        requestAnimationFrame(update);
    }
    ticking = true;
}
function update()
{
    // reset for next onScroll capture
    ticking = false;
    switch(true)
    {
        case latestKnownScrollY > headerOffset && !sticky:
            sticky=true;
            main.style.paddingTop = header.clientHeight+'px';
            header.classList.add('sticky');
            break;
        case latestKnownScrollY < headerOffset && sticky:
            sticky=false;
            main.style.paddingTop = '';
            header.classList.remove('sticky');
            break;
    }
}
document.addEventListener('scroll',onScroll,false);</code></pre>
    <h2>CSS:</h2>
<pre><code>
.sticky {position:fixed;top:0;}
</code></pre>
</div>

</div>
<script>
'use strict';
// browser hardware accel
window.requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame;
var header = document.getElementById('header');
var main = document.getElementById('main');
var headerOffset = header.offsetTop;
var sticky = false;
var ticking = false;
var latestKnownScrollY = 0;
// on scroll
function onScroll() 
{
    latestKnownScrollY = window.pageYOffset || window.scrollY || document.documentElement.scrollTop;
    requestTick();
}
function requestTick() 
{
    if(!ticking) 
    {
        requestAnimationFrame(update);
    }
    ticking = true;
}
function update()
{
    // reset for next onScroll capture
    ticking = false;
    switch(true)
    {
        case latestKnownScrollY > headerOffset && !sticky:
            sticky=true;
            main.style.paddingTop = header.clientHeight+'px';
            header.classList.add('sticky');
            break;
        case latestKnownScrollY < headerOffset && sticky:
            sticky=false;
            main.style.paddingTop = '';
            header.classList.remove('sticky');
            break;
    }
}
document.addEventListener('scroll',onScroll,false);
</script>
</body></html>

Advantages of requestAnimationFrame():

RequestAnimationFrame gives the browser control over how many frames it renders, the number of callbacks is usually 60 times per second, but will generally match the display refresh rate in most web browsers as per W3C recommendation.

  • The animation looks smoother since it uses a consistent frame rate.
  • Rather than being overloaded with rendering tasks, the processor is able to handle other tasks while also rendering the animation. In fact, it is able to determine a frame rate that works with the other tasks it is handling.
  • Power saving: If the web browser current tab loses focus, requestAnimationFrame will stop animating.

Further reading:

https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame