Building a Box That Sticks While You Scroll

Dmitri Lau

Sticky boxes are boxes that stay visible on your browser no matter where you scroll on the page. They are most often used in side bars and header bars to keep the branding and navigation menus visible and reachable at all times. In the old days, sticky boxes were pretty basic and were only stationary on one part of the viewport no matter where you scrolled, as shown in this screenshot of the Yahoo! homepage.

yahoo_2001

And they were pretty easy to implement with CSS too, as shown in the following IE6 workaround.

<style>
  #header {
    position: fixed;
    top: 0px;
  }
  * html #header {
    position: absolute;
    top: expression(document.body.scrollTop);
  }
</style>

But nowadays, webpages have evolved and sticky boxes need to be in different places depending on where the webpage has scrolled to. For example, check out this article’s demo page, yoga shops around the world. Notice how the logo and speech bubbles float gracefully alongside the main content. When you are at the top of the page, the sticky box can be pinned in the middle of the screen. As you scroll down, the sticky box gracefully slides up and then clings on to the top of the viewport for the duration of the session. Then, as you near the bottom of the page (or boundary), the sticky box slides up farther until it disappears from view. It’s a very fluid experience that can be made with just a few lines of code.

The Plan

First, we’ll need a function that gets called whenever the page is scrolled. This function must loop through each of the sticky elements on the page to determine whether the element is:

  1. Below the top edge of the viewport.
  2. Above the top edge of the viewport, and
    • Not touching the bottom edge of its boundary.
    • Touching the bottom edge of its boundary.

Now, let’s get the following skeleton code going:

document.onscroll = onScroll;

function onScroll() {
  var list = getAllStickies();

  for (var i = 0, item; item = list[i]; i++) {
    var bound = getBoundary(item);
    var edge = bound.getBoundingClientRect().bottom;
    var height = item.offsetHeight;
    var top = item.getBoundingClientRect().top;

    if (top < 0) {
      // above the top edge of the viewport
      if (edge > height) {
        // not touching the bottom edge of its boundary
        item.style.position = "fixed";
        item.style.top = "0px";
      } else {
        // touching the bottom edge of its boundary
        item.style.position = "relative";
        item.style.top = -((top - edge) + height) + "px";
      }
    } else {
      // below the top edge of the viewport
      item.style.position = "relative";
      item.style.top = "auto";
    }
  }
}

The functions getAllStickies() and getBoundary() haven’t been defined yet. We’ll revisit them a little later. The getBoundingClientRect() function is a convenient and fast function for returning the position of an element relative to the viewport. Elements above the viewport are negative numbers. By using this function, we only need to check whether the top value is a positive or a negative number.

Our function detects three scenarios for each sticky element:

  1. If the element is below the top edge of the viewport, the element is still part of the page and should be in its natural position so that it scrolls with the page.
  2. If the element is above the top edge of the viewport (i.e., hidden), and is not touching the bottom edge of its boundary, the element should be moved to the top of the viewport and its position set to fixed.
  3. If the element is above the top edge of the viewport (i.e., hidden), and is touching the bottom edge of its boundary, the element should be moved to be just above the boundary edge. In this case, its position is set to relative so that it can scroll with the page.

Now that the logic is in place, let’s discuss semantics.

The Mark

We’ll define a sticky element as an element containing a x-sticky attribute. The sticky is a child or descendant of a boundary element identified with a x-sticky-boundary attribute. The sticky is free to move within the confines of the boundary element. An example sticky and boundary is shown below.

<div x-sticky-boundary="">
  <div x-sticky="">I am a sticky confined within a boundary</div>
</div>

Next, we’ll implement the getAllStickies() and getBoundary() functions we mentioned earlier. We can replace getAllStickies() with:

var list = document.querySelectorAll("[x-sticky]");

Additionally, we can implement getBoundary() to return the first ancestor element with the x-sticky-boundary attribute (or return the body element):

function getBoundary(n) {
  while (n = n.parentNode) {
    if (n.hasAttribute("x-sticky-boundary")) {
      return n;
    }
  }

  return document.body || document.documentElement;
}

Currently, the code only supports one sticky per boundary. But often times we have two or more stickies per boundary that should not conflict with each other. If a second sticky moves to the top of the viewport, it should push the first sticky out of the way.

Previously, we assumed that the bottom edge of the boundary is the boundary limit. We need to modify this to also check for the top edge of the next sticky element that is within the same boundary.

var edge = bound.getBoundingClientRect().bottom;
var nextItem = findNextInBoundary(list, i, bound);

if (nextItem) {
  edge = nextItem.getBoundingClientRect().top;
}

We’ve defined a new function findNextInBoundary(), that loops through an array, starting at a defined index, looking for the next sticky that shares a boundary element with the current sticky.

The Drop

There is one major scenario which we haven’t considered so far. After the page has scrolled, we have dynamically moved a sticky element to another position on the page. This means the original position of the sticky element is not preserved, which means we cannot restore it’s original position when the user scrolls back upwards.

Also, when we make the sticky into a fixed position element, it is pulled out of the document flow, which means the content below it will shift upwards. We want to preserve the space it occupied, so that the content below it doesn’t jump around. To work around this, we need to put a placeholder element in the original position of the sticky. We will also put the sticky inside the placeholder so that it doesn’t affect the nth-child pseudo selector of the placeholder’s siblings. Then whenever we need to restore the sticky’s position, we replace the placeholder with the sticky and discard the placeholder.

One thing to remember is that if we want to get the initial position of a sticky, we should instead get the current position of it’s placeholder. Here’s our updated function:

document.onscroll = onScroll;

function onScroll() {
  var list = document.querySelectorAll("[x-sticky]");

  for (var i = 0, item; item = list[i]; i++) {
    var bound = getBoundary(item);
    var edge = bound.getBoundingClientRect().bottom;
    var nextItem = findNextInBoundary(list, i, bound);

    if (nextItem) {
      if(nextItem.parentNode.hasAttribute("x-sticky-placeholder")) {
        nextItem = nextItem.parentNode;
      }

      edge = nextItem.getBoundingClientRect().top;
    }

    // check if the current sticky is already inside a placeholder
    var hasHolder = item.parentNode.hasAttribute("x-sticky-placeholder");
    var rect = item.getBoundingClientRect();
    var height = rect.bottom - rect.top; // get the height and width
    var width = rect.right - rect.left;
    var top = hasHolder ? item.parentNode.getBoundingClientRect().top : rect.top;

    if (top < 0) {
      if(edge > height) {
        item.style.position = "fixed";
        item.style.top = "0px";
      } else {
        item.style.position = "relative";
        item.style.top = -((top - edge) + height) + "px";
      }

      if (!hasHolder) {  //create the placeholder
        var d = document.createElement("div");

        d.setAttribute("x-sticky-placeholder", "");
        d.style.height = height + "px";  //set the height and width
        d.style.width = width + "px";
        item.parentNode.insertBefore(d, item);
        d.appendChild(item);
      }
    } else {
      item.style.position = "relative";
      item.style.top = "auto";

      if (hasHolder) {  //remove the placeholder
        item = item.parentNode;
        item.parentNode.insertBefore(item.firstChild, item);
        item.parentNode.removeChild(item);
      }
    }
  }
}

function findNextInBoundary(arr, i, boundary) {
  i++;

  for (var item; item = arr[i]; i++) {
    if (getBoundary(item) == boundary) {
      return item;
    }
  }
}

function getBoundary(n) {
  while (n = n.parentNode) {
    if (n.hasAttribute("x-sticky-boundary")) {
      return n;
    }
  }

  return document.body || document.documentElement;
}

The Decoy

To maximize the usefulness of the placeholder, we will also need to copy several CSS properties from the sticky element to the placeholder. For example, we’ll want the margins to be the same so that it occupies exactly the same amount of space. We’ll also want the float property to be preserved, so that it doesn’t mess up floating based grid layouts.

Let’s introduce a function, copyLayoutStyles(), which is called as soon as the placeholder is created to copy the styles over to the placeholder:

function copyLayoutStyles(to, from) {
  var props = {
    marginTop: 1,
    marginRight: 1,
    marginBottom: 1,
    marginLeft: 1
  };

  if (from.currentStyle) {
    props.styleFloat = 1;

    for (var s in props) {
      to.style[s] = from.currentStyle[s];
    }
  } else {
    props.cssFloat = 1;

    for (var s in props) {
      to.style[s] = getComputedStyle(from, null)[s];
    }
  }
}

The Clean Up

Currently we’re setting the element’s position property directly to fixed or relative. Let’s move that call into a CSS stylesheet and use selectors to apply the property. This allows other programmers to override the default behavior if necessary. The CSS stylesheet will look like this:

<style>
  [x-sticky] {margin:0}
  [x-sticky-placeholder] {padding:0; margin:0; border:0}
  [x-sticky-placeholder] > [x-sticky] {position:relative; margin:0 !important}
  [x-sticky-placeholder] > [x-sticky-active] {position:fixed}
</style>

Rather than creating a separate stylesheet, let’s inject this style sheet using JavaScript by creating a temporary element and setting it’s innerHTML with the stylesheet. Then, we can append the result to the document, as shown below.

var css = document.createElement("div");
css.innerHTML = ".<style>" + 
  "[x-sticky] {margin:0}" +
  "[x-sticky-placeholder] {padding:0; margin:0; border:0}" +
  "[x-sticky-placeholder] > [x-sticky] {position:relative; margin:0 !important}" +
  "[x-sticky-placeholder] > [x-sticky-active] {position:fixed}" +
  "<\/style>";
var s = document.querySelector("script");
s.parentNode.insertBefore(css.childNodes[1], s);

Inside the main function we need to replace each occurance of, item.style.position = "fixed", with item.setAttribute("x-sticky-active", ""), so that the CSS selector can match the attribute. In order to make this code shippable, we also need to wrap everything up in a closure to keep the private variables private. We’ll also need to use addEventListener() rather than assigning to document.onscroll to avoid possible clashes. And, while we’re at it, let’s add an API check (shown below), so that our function won’t run in older browsers.

if (document.querySelectorAll && 
    document.createElement("b").getBoundingClientRect)
(function(doc) {
"use strict";

init();

function init() {
  if(window.addEventListener) {
    addEventListener("scroll", onScroll, false);
  } else {
    attachEvent("onscroll", onScroll);
  }

  var css = doc.createElement("div");

  css.innerHTML = ".<style>" + 
    "[x-sticky] {margin:0}" +
    "[x-sticky-placeholder] {padding:0; margin:0; border:0}" +
    "[x-sticky-placeholder] > [x-sticky] {position:relative; margin:0!important}" +
    "[x-sticky-placeholder] > [x-sticky-active] {position:fixed}<\/style>";

  var s = doc.querySelector("script");
  s.parentNode.insertBefore(css.childNodes[1], s);
}

function onScroll() {
  var list = doc.querySelectorAll("[x-sticky]");

  for (var i = 0, item; item = list[i]; i++) {
    var bound = getBoundary(item);
    var edge = bound.getBoundingClientRect().bottom;
    var nextItem = findNextInBoundary(list, i, bound);

    if (nextItem) {
      if (nextItem.parentNode.hasAttribute("x-sticky-placeholder")) {
        nextItem = nextItem.parentNode;
      }

      edge = nextItem.getBoundingClientRect().top;
    }

    var hasHolder = item.parentNode.hasAttribute("x-sticky-placeholder");
    var rect = item.getBoundingClientRect();
    var height = rect.bottom - rect.top;
    var width = rect.right - rect.left;
    var top = hasHolder ? item.parentNode.getBoundingClientRect().top : rect.top;

    if (top < 0) {
      if (edge > height) {
        if (!item.hasAttribute("x-sticky-active")) {
          item.setAttribute("x-sticky-active", "");
        }

        item.style.top = "0px";
      } else {
        if (item.hasAttribute("x-sticky-active")) {
          item.removeAttribute("x-sticky-active");
        }

        item.style.top = -((top - edge) + height) + "px";
      }

      if (!hasHolder) {
        var d = doc.createElement("div");

        d.setAttribute("x-sticky-placeholder", "");
        d.style.height = height + "px";
        d.style.width = width + "px";
        copyLayoutStyles(d, item);
        item.parentNode.insertBefore(d, item);
        d.appendChild(item);
      }
    } else {
      if (item.hasAttribute("x-sticky-active")) {
        item.removeAttribute("x-sticky-active");
      }

      item.style.top = "auto";

      if(hasHolder) {
        item = item.parentNode;
        item.parentNode.insertBefore(item.firstChild, item);
        item.parentNode.removeChild(item);
      }
    }
  }
}

function findNextInBoundary(arr, i, boundary) {
  i++;

  for (var item; item = arr[i]; i++) {
    if (getBoundary(item) == boundary) {
      return item;
    }
  }
}

function getBoundary(n) {
  while (n = n.parentNode) {
    if (n.hasAttribute("x-sticky-boundary")) {
      return n;
    }
  }

  return doc.body || doc.documentElement;
}

function copyLayoutStyles(to, from) {
  var props = {
    marginTop: 1,
    marginRight: 1,
    marginBottom: 1,
    marginLeft: 1
  };

  if (from.currentStyle) {
    props.styleFloat = 1;

    for (var s in props) {
      to.style[s] = from.currentStyle[s];
    }
  } else {
    props.cssFloat = 1;

    for (var s in props) {
      to.style[s] = getComputedStyle(from, null)[s];
    }
  }
}
})(document);

Conclusion

And there you have it! By marking an element with an x-sticky attribute, it scrolls with the page until it gets to the top, and it will linger until it meets the boundary edge where it then disappears up the page.

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

  • http://www.joezimjs.com Joe Zimmerman

    Pretty neat. I don’t have any cases where I could use this right now, but I’ll have to keep this bookmarked just in case. :)

  • unkar

    This doesn’t work well on smartphones… Or mayb it’s just my case, but I still didn’t figured out how to make it work smooth on mobile..

    • Dmitri Lau

      There’s a new “position:sticky” css value that’s supposed to achieve a similar thing, but without the pushing of the elements out of the way when they collide effect.

  • Jingqi Xie

    Use data- prefixed attributes for they’re valid HTML.