Blog-Archiv

Donnerstag, 7. Januar 2016

JS Sticky Bar

I wonder how many users do appreciate a sticky bar. It can be seen in a lot of modern web applications, just look at the popular w3schools page. It's that full-width horizontal bar on top that mostly contains a menu, and scrolls away until it reaches the viewport top, then "sticks" there.

Well, I admit that I myself find it useful:-) Although I do not understand why it has to be done so magically. Why not have a header and a scrollable panel below it? Is this about accessibility, trying to not have scrollbars within the web-page, avoiding the deprecated frames concept?

However, this Blog is about how sticky bars could be implemented. Here is an example.

Scroll down!
The title below is "sticky" and won't scroll out of view.

Aurea prima sata est aetas, quae vindice nullo,

sponte sua, sine lege fidem rectumque colebat.

Poena metusque aberant nec verba minantia fixo

aere legebantur, nec supplex turba timebat

iudicis ora sui, sed erant sine vindice tuti.

Nondum caesa suis, peregrinum ut viseret orbem,

montibus in liquidas pinus descenderat undas,

nullaque mortales praeter sua litora norant.

Nondum praecipites cingebant oppida fossae,

non tuba directi, non aeris cornua flexi,

non galeae, non ensis erant: sine militis usu

mollia securae peragebant otia gentes.

....

[The End]

This article requires know-how about the CSS position property. You could read my past Blog about this, and study it here.

I do not use jQuery here, although I appreciate their work a lot. As a result, the JS code below might not work in old or exotic browsers. Already know youmightnotneedjquery?

Two Ways to Sticky

Basically there are two major ways to implement a "sticky" behaviour:

  • fixed positioning (or absolute when bar in a nested scroll-pane), putting the element to its constant location at the time the scrollbar would scroll it away, and restoring the old state when scrolling back

  • relative positioning, moving the element on every scroll event by changing its top coordinate.

Both have their pros and cons.

From the usability perspective, the fixed positioning is better, because it's more stable, not depending on browser events so strongly. You can not scroll away the bar by fast moves (the example above works with relative positioning, and you can scroll away the bar, try it!).

From a technical perspective, relative positioning is easier, because the bar stays in layout context and just moves relatively. If you change the position property of an HTML element to fixed or absolute, the element will be removed from the layout flow, causing other elements to go into the gap. Further the element will be sized differently concerning padding and margin. A block-element would not have 100% width automatically any more. Etc etc.

I spent a lot of time studying the problems that result from the fixed (absolute) method, experimenting with any kind of element, having different margin / border / padding combinations. It seems an endless story to me. Anyway, in the following I will present simple implementations of both methods. You can go to my homepage to see the current state and source of my efforts around sticky bars.

HTML Example Page

Here is the HTML code I would like to use for a simple implementation of both principles. The sticky element is marked by id=stickybar.

Paste the code into some file, add one of the following JavaScripts, and try it out in your favorite browser, using the file:/// protocol prefix in address line.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="initial-scale=1"/>
  <title>Horizontal Sticky Bar</title>
</head>

<body>

  <div style="text-align: center;">
  
    <p style="padding: 2em;">
      Scroll down!<br>
      The title below is "sticky" and won't scroll out of view.<br>
    </p>
      
    <div id="stickybar" style="padding: 1em 0 1em 0; background-color: green; color: white;">
      Metamorphoses (Ovid)
    </div>
      
    <div style="position: relative; height: 70em;">
      <p>Aurea prima sata est aetas, quae vindice nullo,</p>
      <p>sponte sua, sine lege fidem rectumque colebat.</p>
      <p>Poena metusque aberant nec verba minantia fixo</p>
      <p>aere legebantur, nec supplex turba timebat</p>
      <p>iudicis ora sui, sed erant sine vindice tuti.</p>
      <p>Nondum caesa suis, peregrinum ut viseret orbem,</p>
      <p>montibus in liquidas pinus descenderat undas,</p>
      <p>nullaque mortales praeter sua litora norant.</p>
      <p>Nondum praecipites cingebant oppida fossae,</p>
      <p>non tuba directi, non aeris cornua flexi,</p>
      <p>non galeae, non ensis erant: sine militis usu</p>
      <p>mollia securae peragebant otia gentes.</p>
      <p>....</p>
      
      <div style="position: absolute; bottom: 1em; width: 100%;">
        [The End]
      </div>
      
    </div>
    
  </div>


  <script type="text/javascript">
    "use strict";

    // JS code goes here

  </script>

</body>
</html>

The "relative" Way

This is the technically easier approach, though usability lacks. What happens is that you can scroll away the sticky bar by fast scrollbar movements. There is no workaround for this, because this solution depends completely on scroll events sent by the browser. On fast moves, the final events are not sent by the browser any more, so the stick bar stays behind.

Following outlines what has to be done for this solution.

  • Install a "scroll" event listener
  • detect the moment when the sticky bar scrolls out of view, generally this happens when element.getBoundingClientRect() delivers a negative top coordinate
  • remember the page-absolute top-coordinate somewhere in the element (as attribute or property), because it will change now
  • set CSS position to relative, and top to the positive inversion of the negative boundingClientRect.top
  • on every scroll event, further adjust the top coordinate of the bar
  • when the viewport scrolls back across the remembered top-coordinate, restore the element state.

First we need some functions that give us browser viewport informations.

    var browserViewportY = function() {
      return (window.pageYOffset !== undefined) ? window.pageYOffset : document.documentElement.scrollTop;
    };
    
    var browserViewportHeight = function() {
      return (window.innerHeight !== undefined) ? window.innerHeight : document.documentElement.clientHeight;
    };
    
    var browserPageHeight = function() {
      return document.documentElement.getBoundingClientRect().height;
    };

The viewport is the currently visible area of the page. The viewportY variable gives the scroll-top, which is the distance of the page-top to the viewport-top. The getBoundingClientRect() call returns viewport-relative coordinates.

Next shows the scroll-event processing function that uses all others to keep the bar sticky. For this it calls setSticky() (init), adjustSticky() (work) and unsetSticky() (exit) under certain conditions.

    var onScroll = function() {
      var viewportY = browserViewportY();
      var boundingClientRect = stickyElement.getBoundingClientRect();
      
      if (stickyElement.initialTop === undefined)  { /* not being sticky */
        if (boundingClientRect.top < 0) { /* going out of view */
          setSticky(stickyElement, boundingClientRect.top);
          /* set the top-offset to observe now */
          stickyElement.initialTop = viewportY + boundingClientRect.top;
        }
      }
      else if (stickyElement.initialTop < viewportY) { /* further going out of view, adjust y */
        if (browserPageHeight() > viewportY + browserViewportHeight()) /* not page end reached */
          adjustSticky(stickyElement, viewportY);
      }
      else if (stickyElement.initialTop > viewportY) { /* coming into view */
        unsetSticky(stickyElement);
        stickyElement.initialTop = undefined;
      }
    };

Here is a naive implementation of the things to do when the bar gets sticky, must be adjusted, or goes back to initial state.

    var setSticky = function(stickyElement, boundingClientRectTop) {
      stickyElement.style.position = "relative";
      stickyElement.style.top = (- boundingClientRectTop)+"px";
      stickyElement.style["z-index"] = 1; /* else transparent */
      if ( ! stickyElement.style["background-color"])
        stickyElement.style["background-color"] = "white"; /* else transparent */
    };
    
    var adjustSticky = function(stickyElement, viewportY) {
      stickyElement.style.top = (viewportY - stickyElement.initialTop)+"px";
    };
    
    var unsetSticky = function(stickyElement) {
      stickyElement.style.position = "";
    };

Because in JavaScript properties are created when they are used, I can store the initial top-coordinate into the element's property initialTop. After setting it, it is the criterion for detecting the reset-moment.

Care must be taken to not scroll down endlessly. When moving the bar down, it will create more scroll space dynamically. The condition if (browserPageHeight() > viewportY + browserViewportHeight()) prevents from going beyond the page end.

Here is the remaining code, defining the missing stickyElement and installing the event-listener, in a self-executing JS capsule.

1
2
3
4
5
6
7
8
9
  (function() {

    var stickyElement = document.getElementById("stickybar");
    
    ....
  
    window.addEventListener("scroll", onScroll);
    
  })();

Paste all three JS snippets above into the capsule where the "...." is, and the capsule into the HTML page above, and try out if it works.

The "fixed" Way

The problem with the "fixed" solution is that the sticky HTML element gets removed from the layout flow when set to fixed or absolute. The place where it has been must be filled with a place-holder element that has the same size. Further the sticky bar behaves differently when being no more in layout flow of the page, for example the default 100% width of a div does not apply any more, it collapses to its "natural" size needed by contained text. Thus we need to size it, but this again causes a wrong width when the browser window is resized after.

Here is the outline of things to do for this solution. (Mind that this will need a lot of fixes when the bar or its environment is managed by complex CSS. Mind further that I won't solve the "absolute" case here. You may find info about bridging fixed to absolute here.)

  • install a scroll listener
  • detect the moment when the sticky bar scrolls out of view (same as in "relative" solution)
  • remember the page-absolute top-coordinate
  • create a place-holder div with same width and height as the bar, and insert it before the bar into document
  • remember the place-holder so that it can be removed when restoring to normal state
  • fix the width of the bar to its current width to prevent it from collapsing
  • set CSS position to fixed (or absolute when in nested scroll-pane)
  • set top to zero
  • when the viewport scrolls back across the remembered top-coordinate, restore the element state, and remove the place-holder element.

Here are the JS snippets for this solution.

    var browserViewportY = function() {
      return (window.pageYOffset !== undefined) ? window.pageYOffset : document.documentElement.scrollTop;
    };

The y-offset of the viewport within the scrollpane is all information we need for this solution.

    var onScroll = function() {
      var viewportY = browserViewportY();
      
      if (stickyElement.initialTop === undefined)  { /* not being sticky */
        var boundingClientRect = stickyElement.getBoundingClientRect();
        
        if (boundingClientRect.top < 0) { /* going out of view */
          setSticky(stickyElement, boundingClientRect.left);
          /* set the top-offset to observe now */
          stickyElement.initialTop = viewportY + boundingClientRect.top;
        }
      }
      else if (stickyElement.initialTop > viewportY) { /* coming into view */
        unsetSticky(stickyElement);
        stickyElement.initialTop = undefined;
      }
    };

This event-processing is simpler in that it does not update the relative position always.

But of course the setSticky() function is much more complicated. And a lot of fixes will have to be done here when being in a complex CSS environment.

    var setSticky = function(stickyElement, left) {
       /* create a placeholder with same dimensions */
       var placeHolder = document.createElement("div");
       placeHolder.style.width = stickyElement.clientWidth+"px";
       placeHolder.style.height = stickyElement.clientHeight+"px";
       
       /* insert placeholder before stickyElement */
       stickyElement.parentElement.insertBefore(placeHolder, stickyElement);
       stickyElement.myPlaceHolder = placeHolder; /* remember for removal */

       stickyElement.style["z-index"] = 1; /* else transparent */
       if ( ! stickyElement.style["background-color"])
         stickyElement.style["background-color"] = "white"; /* else transparent */
       
       /* fix stickyElement to viewport */
       stickyElement.style.top = 0;
       stickyElement.style.left = left+"px";
       stickyElement.style.width = placeHolder.style.width;
       stickyElement.style.position = "fixed";
    };
    
    var unsetSticky = function(stickyElement) {
      stickyElement.parentElement.removeChild(stickyElement.myPlaceHolder);
      stickyElement.myPlaceHolder = undefined;
      stickyElement.style.position = "";
      stickyElement.style.width = "";
    };

The place-holder element simply gets stored into the element-property myPlaceHolder that will be created by JS when accessed that way.

The JS capsule and initialization is the same as before.


Never forget: everything is relative!
Happy bug fixing!




Keine Kommentare: