How To Build A jQuery-free “Companion Nav”

One delimma I constantly run into is whether to use jQuery on a project that I have already set up without it. I think we have all been in a similar place - get a project set up from scratch firmly saying "no jQuery this time" and it goes fine, right up to the moment when you need to build out that slider or sticky nav. After much deliberation, you inevitably cave and pull in jQuery and use a handy plugin you have used in the past to get the job done.

I believe jQuery is a great tool, and I'm often relieved when I get on a project that makes use of it. However, as many people have pointed out in the last couple of years, you probably don't need jQuery on your project. It's bigger, slower and it's less flexible than standard JavaScript, especially when used in today's client-side applications.

On a recent project, the designer wanted the sidebar navigation to follow the user as she or he scrolled within a certain section. The rest of the project was light interaction-wise, so I could not rationalize using jQuery just for this small feature. Instead of caving and pulling in jQuery, I decided to do this with plain JavaScript.

Here's the final product:

a demo of the ollow nav in action

The following steps explain how you can build it yourself, but if you want to play with a demo while you follow along, get it from this repo.

Step 1: Adding the Markup and Styles

To start with, we need some basic markup to base our scripts (and styles) off of:

 // index.html <section class="all-items" id="followContainer"> <h2>Follow Nav Section</h2> <div class="container"> <nav> <ul id="followNav"> <li><a class="nav-link" data-name="item-[i]" href="#item-[i]">Item [i] Group</a></li> </ul> </nav> <div class="item-groups"> <section class="item-group"> <h3 tabindex="0" id="item-[i]">Item [i] Group</h3> <img /> <p></p> </section> </div> </div> </section> 

This establishes a <nav> with a list of links inside ul#followNav, and a group of correlating <section>s inside div.project-groups.

A few other things to note:

  • The number of <li>s within the ul#followNav must match the number of <section>s within the div.project-groups.
  • The [i] placeholders, as you can guess, need to have a 1:1 relationship, meaning if you add an <li>, be sure to give it a unique [i] value and have a corresponding section.project-group below with a matching [i] value. The best way to keep this straight is to start at 1 and go up by 1 for each new item, like project-1, project-2, project-3 and so on.
  • The tabindex="0" on the <h3> allows users who are not navigating your site with a mouse to tab through the sections. It's always a good idea to keep accessibility in mind when building your projects.

Since this looks pretty ugly by default, let's add some basic styles.

 // global.css .container { display: flex; flex-direction: row; } nav { float: left; margin-right: 20%; width: 10%; } nav ul { transition: transform 0.3s ease-out; } nav a { color: #1496bb; display: block; font-size: 18px; padding: 10px; text-decoration: none; } .item-groups { float: left; margin-bottom: 40px; width: 60%; } .item-groups img { display: block; margin: 0 auto; width: 500px; } h1 { font-size: 32px; margin: 50px auto; } h2 { font-size: 28px; margin-bottom: 18px; } h3 { border-bottom: 1px solid #ccd3d6; padding: 18px 0 5px; margin-bottom: 10px; text-align: center; } 

You will notice I use flexbox in this example. If you aren't using flexbox yet and you can, I highly recommend it. There are many great resources for getting started, including this stellar CSS-Tricks guide.

Step 2: Building the Scripts

Now that we have a basic structure and basic styles, let's get into the scripts.

You can add all this inline to your HTML doc, but I'd recommend going with best practices and saving the following as app.js and placing it in the typical project structure of /assets/javascripts/app.js and be sure to call it in before your <body> tag closes.

The first thing we will need to do is set up a namespace, because it's always a good idea to do that, even if you aren't pulling in third party scripts.

 // app.js // setting up namespace for project var vigetHowTo = vigetHowTo || {}; 

Now that we have a namespace, let's define a custom function and a few variables to get going:

 // app.js ... /* A basic module for sticking nav to window when its top edge is in line with window's top edge */ vigetHowTo.followNavAdjust = function() { // target to get 'stuck' plus its parent container var followNav = document.getElementById('followNav'); // parent container var followContainer = document.getElementById('followContainer'); // gathering a few heights to set up a scroll range var followNavHeight = followNav.offsetHeight; var followNavOffset = followContainer.lastElementChild.offsetTop; var followContainerOffset = followContainer.offsetTop; var followContainerHeight = followContainer.offsetHeight; // followNavHeight * 2 adds extra space to account for the height in calculations var scrollMaxRange = (followContainerHeight + followContainerOffset) - (followNavHeight*2); // rescroll checking var scrollPosition = window.scrollY; 

Now that the browser has all the heights collected, setting up a couple of conditionals is all we need to get it going:

 // app.js ... // if the scroll position goes less than the range of the section, reset it if (scrollPosition < followNavOffset) { = 'translateY(0px)'; // if the scroll position is beyond the range of the section, set it to bottom } else if (scrollPosition > scrollMaxRange) { = 'translateY(' + ( followContainerHeight - (followNavHeight*2) )+ 'px)'; } // otherwise, it is in the range and needs to follow the scroll position else { = 'translateY(' + (scrollPosition - followNavOffset) + 'px)'; } } 

And finally, let's add an init function to fire this, along with all the other scripts we add, when the page loads. Add that to the end of your app.js file like so:

 // app.js ... vigetHowTo.init = function() { vigetHowTo.followNavAdjust(); }; // scripts to fire on page load vigetHowTo.init(); 

Great! That wasn't too bad, and we now have a well put-together module to plop in and it will work.

Step 3: Adding Polish

After all this, we have one problem: we set the var scrollPosition to constantly update. That gets pretty heavy on the browser and if you have other stuff going on your project, this will get pretty laggy. Fortunately there is a really cool method in underscore.js called debounce that allows a defined wait period before calling a function. Ah, but what was that about me complaining about bringing in unneccessary libraries? You are right. I should not bring in all of underscores just for this. Fortunately, underscores is super easy to parse out and use in parts. In fact David Walsh wrote his own version of it that we can glean from.

Make another file in the same directory as app.js, and call it debounce.js.In it, add David Walsh's debounce function:

 // debounce.js /* Returns a function, that, as long as it continues to be invoked, will not be triggered. The function will be called after it stops being called for N milliseconds. If immediate is passed, trigger the function on the leading edge, instead of the trailing. Taken from */ var debounce = function(func, wait, immediate) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; if (!immediate) func.apply(context, args); }; var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); } } 

And let's add it into our HTML file, just above where we call app.js. Once that is done, take out the vigetHowTo.followNavAdjust from the init function, so we can call it in an event listener like so:

 // app.js ... vigetHowTo.init = function() { // ... }; // scripts to fire on page load vigetHowTo.init(); // scripts to fire on page scroll window.addEventListener('scroll', debounce(vigetHowTo.followNavAdjust, 200)); 

That's great! Now we have a nav that follows users, and if you want to increase or decrease the speed in which it follows, you can change the 200 in the event listener.

Now, totally optional, but if you want to add a little more slickness to this, I recommend adding a quick animation function so that when a user clicks on a link in the follow nav, the page animates a "scroll to" rather than a quick, abrupt jump to it. Fortunately, like most things we build, we can leverage small snippits other people built that we can use. I found this helpful smooth scrolling function.

Let's add it into another file named smooth-scroll-to.js and place it in the same directory as app.js and debounce.js:

 // smooth-scroll-to.js /* Smoothly scroll element to the given target (element.scrollTop) for the given duration Returns a promise that's fulfilled when done, or rejected if interrupted Taken from */ var smoothScrollTo = function(element, target, duration) { target = Math.round(target); duration = Math.round(duration); if (duration < 0) { return Promise.reject("bad duration"); } if (duration === 0) { element.scrollTop = target; return Promise.resolve(); } var start_time =; var end_time = start_time + duration; var start_top = element.scrollTop; var distance = target - start_top; // based on var smooth_step = function(start, end, point) { if(point <= start) { return 0; } if(point >= end) { return 1; } var x = (point - start) / (end - start); // interpolation return x*x*(3 - 2*x); } return new Promise(function(resolve, reject) { // This is to keep track of where the element's scrollTop is // supposed to be, based on what we're doing var previous_top = element.scrollTop; // This is like a think function from a game loop var scroll_frame = function() { if(element.scrollTop != previous_top) { reject("interrupted"); return; } // set the scrollTop for this frame var now =; var point = smooth_step(start_time, end_time, now); var frameTop = Math.round(start_top + (distance * point)); element.scrollTop = frameTop; // check if we're done! if(now >= end_time) { resolve(); return; } // If we were supposed to scroll but didn't, then we // probably hit the limit, so consider it done; not // interrupted. if(element.scrollTop === previous_top && element.scrollTop !== frameTop) { resolve(); return; } previous_top = element.scrollTop; // schedule next frame for execution setTimeout(scroll_frame, 0); } // boostrap the animation process setTimeout(scroll_frame, 0); }); } 

Then, we need to call it into our main HTML file before we call in the app.js file.

Once everything is in place, we need to leverage the power of smoothScrollTo in a custom function in app.js that hooks it into the DOM elements in out project like so:

 // app.js ... /* Handles smooth scrolling animations in nav */ vigetHowTo.smoothNavScroll = function() { var navLinks = document.querySelectorAll('.nav-link'); var animateScroll = function(e) { e.preventDefault(); var slug = this.getAttribute('data-name'); var scrollTarget = document.getElementById(slug).offsetTop; // using function defined in smooth-scroll-to smoothScrollTo(document.documentElement, scrollTarget, 200); smoothScrollTo(document.body, scrollTarget, 200); } // loop through the navLinks array and add an event listener to each for (var i = 0; i < navLinks.length; i++) { navLinks[i].addEventListener('click', animateScroll, false); } } 

Lastly, add this function to our empty init function and you are good to go:

 // app.js ... vigetHowTo.init = function() { vigetHowTo.smoothNavScroll(); }; // scripts to fire on page load vigetHowTo.init(); 

All finished! Using some custom functions and vanilla JavaScript, we have done something native jQuery can do easily, but we have cut down on load time, project bloat and have made it easier to incorporate other front end frameworks down the road. If you have any questions about JavaScript methods used or anything else that caught your eye, be sure to comment below.

Again, if you want to get a version of this project for yourself to play around with, check out the repo I made. Also, be sure to check out an example of this very follow nav in the wild at and see all the other cool open source projects Viget has made!

One final note: in the examples above, I did everything by hand, but I normally use a task runner like Gulp to make my workflow more productive. If you have never used a task runner, or you have and you are looking for an upgrade, I highly recommend Dan Tello's Gulp Starter. Not only does it use Gulp for your Sass, but it also leverages Babel and Webpack so that you can be using JavaScript's next-gen version, ESCMAScript 6 today.

Ben is a front-end developer in our Boulder, CO, office, where he builds apps with rich animations and thoughtful interactions for our clients.

More posts by Ben