Tuesday, November 6th, 2012

Stalker, a JQuery Plugin That Allows Elements to Follow Users on a Page

By , Software Engineer

When we designed the new header for the Box Webapp, one of the goals was to make navigating the site easier and more intuitive, which we decided could be achieved by putting search front and center alongside access to the main areas of the application.  In order to have those controls readily at hand and for an enhanced user experience, we decided to have the header follow users as they scroll down a page.

(You can see a very simple demo of how the plugin works here.)

Design decision: Building a jQuery Plugin

While similar design patterns exist all over the web, I wasn’t able to find an existing solution that fit our needs.  So I decided to build my own jQuery plugin to manage this behavior, which gave us additional flexibility to use the “following while you scroll” behavior elsewhere in the application, and let us release the code for other developers to use in their own projects.

I started with the basic use case: the header should follow a user down the page and then return to its original position at the top of the page when the user scrolls back up.  Starting from the jQuery Boilerplate, I added the core functionality to get the element to follow the user as they scroll:

function setPosition(edge)
{
    me.stalking = true;
    var initial = {position: 'fixed'}, ending = $.extend({}, initial);
    initial[edge] = -(me.jElement.outerHeight()) + 'px';
    ending[edge] = parseInt(me.options.offset) + 'px';

    var handler = function()
    {
        // give the element custom style while stalking; by default,
        //  force the element to have its original width and appear on top
        var stalker_css = $.extend({width: me._baseWidth + 'px', 'z-index': 999999}, me.options.stalkerStyle);
        me.jElement.css(stalker_css);

        me.jElement.css(ending);
    };

    if (me.options.delay)
    {
        setTimeout(handler, me.options.delay);
    }
    else
    {
        handler();
    }
}

var pageTop = $(document).scrollTop();
var viewportHeight = $(window).height();
var pageBottom = pageTop + viewportHeight;
if (me.options.direction == 'down')
{
    if (me._baseOffset.top < pageTop)
     {
         if (!me.stalking)
         {
             setPosition('top');
         }
     }
     else
     {
         me.jElement.css(me._baseCSS);
         me.stalking = false;
     }
 }
 else
 {
     if (me._baseOffset.top + me.jElement.outerHeight() > pageBottom)
     {
         if (!me.stalking)
         {
             setPosition('bottom');
         }
     }
     else
     {
         me.jElement.css(me._baseCSS);
         me.stalking = false;
     }
}

Smooth Scrolling

To ensure that the scrolling experience remains smooth for the user, I used a placeholder with the same height as the original element to keep the page from shifting when the “following” element gets removed from the document flow. In the code, I created a simple placeholder element…

me.placeholder = $('<div></div>').css({height: this.jElement.outerHeight(), width: this._baseWidth});

…and inserted it when the element starts following:

me.jElement.before(me.placeholder).css(ending);

Rather than using position: absolute and having to adjust the position of the element on each scroll event, I chose to use position: fixed and set it only once for efficiency.  Since the scroll event fires for each pixel the user scrolls up or down, it was imperative to do as little work as possible for each event.

Issue #1: Element Width

One of the first stumbling blocks I discovered while developing the basic behavior was that setting the position of the element wasn’t enough.  Because our header has width: 100%, taking it out of the document flow with position: fixed actually caused it to shrink, since it had no container to expand into. Since it gave itself just the width it needed to contain its children, I had to manually give the element a width while it was following.

Issue #2: Restoring CSS

With that solved, the hard part was to put the element back when the user scrolled back up.  I started by just saving and restoring all the CSS properties that I planned to alter, as well as added functionality to apply some extra styles to the element while it was following. I used the extra style functionality to put a small drop shadow beneath the header, so it didn’t look too flat and so that user has some indication that they’re not really at the top of the page.

Issue #3: Page Resizing

The first problem I ran into with this design was when the page was resized, the header’s width didn’t change with the page, which looked pretty bad.  To solve this problem, I leveraged the placeholder element created earlier.  Since the placeholder was in the document flow in place of the original element, its width should be the right one to set for the “following” element.  To take advantage of this, I changed the placeholder from a generic <div> to a shallow clone of the “following” element, like so:

// we also need a placeholder to keep the document from reflowing
// use a clone to keep styles (esp. those related to width) but remove
//  children to reduce id conflicts
this.placeholder = this.jElement.clone(false).empty().css('height', this.jElement.outerHeight());

Tweaking the Design

This also challenged my assumptions about how the element could and couldn’t change while it was “following”, and prompted another change: rather than saving and restoring CSS properties on the same element, I again used cloning to better preserve the original state of the “following” element so that when it was restored, the style changes made to it were not included. I made a clone of the “following” element in its original state:

this._jElementClone = this.jElement.clone(true, true);

Then I did some swapping around to restore everything back to its original state:

/**
 * Restores an element to its original state after stalking
 *  by refreshing it with its clone
 */
function restoreOriginalState()
{
    // discard the stalker and all its weird inline styles
    me.jElement.remove();

    me.placeholder.replaceWith(me._jElementClone);

    // make the old clone the element we're tracking
    me.jElement = me._jElementClone;

    // create a new clone, so the clone and element aren't the same thing
    me._jElementClone = me._jElementClone.clone(true, true);
    me.stalking = false;
}

The second obstacle I ran into was that there are a lot of controls in the header which depended on various event handlers to work correctly.  Cloning the header and all its descendants was keeping the event handlers associated with the header intact, but it meant that any handlers elsewhere that had cached references to elements in the header would no longer work, since it was now possible that the header could have been swapped out for a clone of itself.  Since the only element I really cared about cloning was the “following” element itself (in this case the header <div>) — its descendants didn’t matter for the purposes of keeping styles consistent.  So instead of a (more expensive) deep recursive clone of the element and everything inside it, I could just use a deep clone of the element and “scoop out” all its children when swapping in the clone:

function restoreOriginalState()
{
    // discard the stalker and all its weird inline styles
    me.jElement.detach();

    // rip the guts out of the original and dump them into the clone
    var contents = me.jElement.contents();
    me._jElementClone.empty().append(contents);

    me.placeholder.replaceWith(me._jElementClone);

    // make the old clone the element we're tracking
    me.jElement = me._jElementClone;

    // create a new clone later, when we start stalking again
    me.stalking = false;
}

Note that the code also delays the creation of the clone until the very last moment before the element starts following again, so that any changes to it between the time the element stops following and starts following again will be saved.

Results

Given how useful it has been for both our users and developers, we have open-sourced the plugin at https://github.com/box/stalker — check it out if you’re interested! (You can see a very simple demo of how the plugin works here.)

And if you end up using it for a project, or have any suggestions for how it might be improved, I’d love to hear about it!  You can send me a note at mwiller@box.com.

By ,

Software Engineer

See all of Matt's articles.

  • Pingback: Show HN: Stalker, a jQuery Plugin That Follows Users on a Page | My Daily Feeds

  • Liam

    Nice job, nice to see that you care about smooth scrolling – a lot of the time people don’t care.

    You might want to check out this article, if you haven’t seen it already:

    http://updates.html5rocks.com/2012/08/Stick-your-landings-position-sticky-lands-in-WebKit

    This is years away from widespread adoption, but it might be worth feature-detecting in your plugin since the pure-CSS version will give smoother performance.

    • Matt Willer

      Absolutely, when the implementation of this settles I think it would definitely be worth feature-detecting this.

  • http://www.facebook.com/reubenpressman Reuben Pressman

    We’ve been using Waypoints http://imakewebthings.com/jquery-waypoints/ is there a difference in functionality?

    • Matt Willer

      I’m not very familiar with it, but the Waypoints plugin looks like it’s a more general use case. We were looking for something that did one thing well. From the looks of it, you could probably use Waypoints to get the same basic behavior, but it might be hard to account for all the edge cases and advanced features the Stalker plugin handles.

  • http://mover.io/ Eric Warnke, Mover.io

    This is neat, especially for the example you provided, where the stalker is originally below some other content in the page.

    However, in production in Box you don’t have any content above the stalker. Isn’t this method a tad overkill at this point when you could just do some simple fixed position magic with CSS and not require an extension altogether?

    • Matt Willer

      Actually, sometimes there is content above the main header! Enterprise administrators have an extra bar above it that they can use to switch between their own account and the Admin Console. Also, when you’re previewing a file on Box, the bar with Comment, Assign Task, and Like options sticks to the bottom of the screen until you scroll far enough down to see the comments (assuming your window isn’t tell enough to just display all that at once) — we use this plugin there, too.

  • Pingback: Stalker, a JQuery Plugin for Sticky Elements | Leon Atkinson