Make Internal Links Scroll Smoothly with JavaScript

When they’re navigating through a long document, users often are confused or disoriented when they click a link that jumps to another location in that same document.

Are they on the same page, or a different page? Should they scroll more from here? What’s going on?

The answer to this problem is to scroll the user through the document to the linked location, like this. In this tutorial, we’ll use a smattering of JavaScript to ensure that links that are internal to the document scroll the user to their destination, rather than jumping straight there and confusing users.

Finding Internal Links

First, we need to identify all the links in the document, and then work out which of them are internal ones. Getting a list of all the links is easy:

  var allLinks = document.getElementsByTagName('a');

We need to walk through this list, and work out which of the links we’ve found are internal. An internal link will have a hash (#) symbol in it, and it will point to the document we’re currently looking at. The useful location object tells us about the URL of the document we’re looking at now, so try this:

  for (var i=0;i<allLinks.length;i++) { 
 var lnk = allLinks[i];
   if ((lnk.href && lnk.href.indexOf('#') != -1) &&  
       ( (lnk.pathname == location.pathname) ||
   ('/'+lnk.pathname == location.pathname) ) &&  
       (lnk.search == location.search)) {
          DO SOMETHING WITH THE LINK HERE
   }
 }

Here, the for loop walks through the list of links in the document, and we check for three things:

  1. Does the link contain a hash?
    We check this using the link’s href property, and the indexOf() function to find the location of one string in another.

  2. Is the link the same as the current location?
    Links (and the location object) have a pathname attribute. The pathname of the URL http://www.sitepoint.com/about/who/mharbottle.php is /about/who/mharbottle.php in some browsers, and about/who/mharbottle.php in others (note the presence or absence of the first slash). We must check for both.

  3. Is the querystring the same as the current location?
    The querystring is everything that appears after the ? in a url; this is obviously important if your site is database driven. JavaScript defines a search attribute on location and links that contain the querystring.

If each of these questions is true, then we know the link is an internal one, and we can set it to scroll to its destination.

Scroll, Don’t Jump!

Now we’ve identified an internal link, we want to make it scroll when it’s clicked. To do this, we’ll need to attach an onclick event handler to the link. In days of old, when Web developers were bold, many thought (well, I did) that event handlers were set on a link within the HTML:

<a href="http://www.sitepoint.com/" onclick="myEventHandler()">

But this isn’t really the truth; instead, you should attach an event listener to the link object. The W3C specifies a standard method to do this, as does Internet Explorer; Scott Andrew has usefully provided a function to handle both:

function ss_addEvent(elm, evType, fn, useCapture) 
// addEvent and removeEvent
// cross-browser event handling for IE5+,  NS6 and Mozilla
// By Scott Andrew
{
 if (elm.addEventListener){
   elm.addEventListener(evType, fn, useCapture);
   return true;
 } else if (elm.attachEvent){
   var r = elm.attachEvent("on"+evType, fn);
   return r;
 }
}

So, in our loop over the links, we call this script to attach a smooth-scroll function to the internal link:

ss_addEvent(lnk,'click',smoothScroll);

How to Scroll

Of course, we have to actually have a smoothScroll() function, too. This is the complicated aspect, because it’s all about finding an object’s position on the page, and different browsers implement this in various ways. The marvelous Andrew Clover has written a summary of how to find this position across browsers and we’ll use this solution extensively here.

First, our smoothScroll function is an event handler, so, when it’s called (i.e. when a user clicks one of our internal links) we need to retrieve the link that was clicked. Netscape-class browsers pass an event object to each handler; Internet Explorer stores these details in the global window.event object.

  if (window.event) {  
   target = window.event.srcElement;  
 } else if (e) {  
   target = e.target;  
 } else return;

This code sets the clicked link as the target in a cross-browser fashion. …well, nearly. Mozilla will sometimes pass you the text node within a link as the clicked-on item. We need to check whether target is a text node (i.e. whether its nodeType is 3), and take its parent if it is.

if (target.nodeType == 3) { target = target.parentNode; }

Just to be paranoid, we also check that what we’ve got is an A tag, in case we’ve missed something:

if (target.nodeName.toLowerCase() != 'a') return;

Now, we need to find the destination: the <a name> tag that corresponds to the part after the hash in our clicked-on link. Links have a hash attribute that contains the # and the section that appears after it in the URL, so let’s now walk through all the links in the document and check whether their name attribute is equal to the hash part of the clicked-on link:

  // First strip off the hash (first character)  
 anchor = target.hash.substr(1);  
 // Now loop all A tags until we find one with that name  
 var allLinks = document.getElementsByTagName('a');  
 var destinationLink = null;  
 for (var i=0;i<allLinks.length;i++) {  
   var lnk = allLinks[i];  
   if (lnk.name && (lnk.name == anchor)) {  
     destinationLink = lnk;  
     break;  
   }  
 }  
 // If we didn't find a destination, give up and let the browser do  
 // its thing  
 if (!destinationLink) return true;

We know what we clicked on, and what that points to. Now all we need to know is where we are in the document, and what our destination is. This is where Andy Clover’s notes are invaluable. First, we find the position of the destination link:

  var destx = destinationLink.offsetLeft;   
 var desty = destinationLink.offsetTop;  
 var thisNode = destinationLink;  
 while (thisNode.offsetParent &&  
       (thisNode.offsetParent != document.body)) {  
   thisNode = thisNode.offsetParent;  
   destx += thisNode.offsetLeft;  
   desty += thisNode.offsetTop;  
 }

Note that we loop through offsetParents until we get to the document body, as IE requires. Next, work out where we are currently located:

function ss_getCurrentYPos() {  
 if (document.body && document.body.scrollTop)  
   return document.body.scrollTop;  
 if (document.documentElement && document.documentElement.scrollTop)  
   return document.documentElement.scrollTop;  
 if (window.pageYOffset)  
   return window.pageYOffset;  
 return 0;  
}

IE5 and 5.5 store the current position in document.body.scrollTop, IE6 in document.documentElement.scrollTop, and Netscape-class browsers in window.pageYOffset. Phew!

The way we actually handle the scrolling is to use setInterval(); this thoroughly useful function sets up a repeating timer that fires a function of our choice. In this case, we’ll have our function move the browser’s position one step closer to the destination; setInterval() will call our function repeatedly, and when we reach the destination, we’ll cancel the timer.

First, use clearInterval() to turn off any timers that are currently running:

  clearInterval(ss_INTERVAL);
ss_INTERVAL is a global variable in which we will later store the ouput of setInterval(). Next, work out how big each step should be:

  ss_stepsize = parseInt((desty-cypos)/ss_STEPS);
ss_STEPS is defined in the script to be the number of steps we take from target to destination. Our "scroll one step" function is called ss_scrollWindow and takes three parameters: 

  • how much to scroll
  • the destination position
  • the destination link itself

We need to construct a call to this in a string, and pass that string to setInterval, along with the frequency with which we want the call repeated:

  ss_INTERVAL = setInterval('ss_scrollWindow('+ss_stepsize+','+desty+',"'+anchor+'")',10);

Notice how we're building up a string that's a call to ss_scrollWindow(), rather than just calling ss_scrollWindow() directly -- this is one of the most confusing things about setInterval().

Once we've done that, we have to stop the browser taking its normal course by obeying the link and jumping directly to the destination. Again, this happens differently in different browsers. To stop the browser handling this event normally in Internet Explorer, use:

  if (window.event) {  
   window.event.cancelBubble = true;  
   window.event.returnValue = false;  
 }

Notice the check for window.event to ensure that we're using IE.

To do the same in Netscape-class browsers, use this code:

  if (e && e.preventDefault && e.stopPropagation) {  
   e.preventDefault();  
   e.stopPropagation();  
 }
Scrolling a Step

One last thing: how do we actually do the scrolling? The key function here is window.scrollTo(), to which you pass an X and Y position; the browser then scrolls the window to that position. One minor wrinkle is that you can't scroll all the way to the bottom. If the Y position you pass in is less than a window's height from the bottom of the document, the browser will scroll down only as far as it can -– obviously it can't go right down to the link if the distance to the bottom of the page is less than the height of the window.

Now, we need to check for that; the best way to do so is to see whether the positions before and after the scroll are the same:

function ss_scrollWindow(scramount,dest,anchor) {  
 wascypos = ss_getCurrentYPos();  
 isAbove = (wascypos < dest);  
 window.scrollTo(0,wascypos + scramount);  
 iscypos = ss_getCurrentYPos();  
 isAboveNow = (iscypos < dest);  
 if ((isAbove != isAboveNow) || (wascypos == iscypos)) {  
   // if we've just scrolled past the destination, or  
   // we haven't moved from the last scroll (i.e., we're at the  
   // bottom of the page) then scroll exactly to the link  
   window.scrollTo(0,dest);  
   // cancel the repeating timer  
   clearInterval(ss_INTERVAL);  
   // and jump to the link directly so the URL's right  
   location.hash = anchor;  
 }  
}

Note that, because we scroll in specific integral increments, this step might have taken us past our destination. Thus, we check whether we were above the link before and after the scroll; if these two locations are different, we've scrolled past the link, and as such, we've finished. If we're finished, we cancel the timer and set the page's URL (by setting a bit of the location object) so that it looks as if the browser had handled the link.

Making the Effect Happen

The easiest way to apply this effect to your pages is to drop the code into a file called smoothscroll.js and include that file in your page using this code:

<script src="smoothscroll.js" type="text/javascript"></script>

This approach follows the principles of unobtrusive DHTML, making it easy for everyone to use. For the solution to work, the script needs to be run by something; we put the code from our first step (looping over the links to find those that are internal) into a function ss_fixAllLinks(), and bind that to the window's onload event using Scott Andrew's function:

ss_addEvent(window,"load",ss_fixAllLinks);

The whole code looks like this:

function ss_fixAllLinks() {   
 // Get a list of all links in the page  
 var allLinks = document.getElementsByTagName('a');  
 // Walk through the list  
 for (var i=0;i<allLinks.length;i++) {  
   var lnk = allLinks[i];  
   if ((lnk.href && lnk.href.indexOf('#') != -1) &&    
       ( (lnk.pathname == location.pathname) ||  
   ('/'+lnk.pathname == location.pathname) ) &&    
       (lnk.search == location.search)) {  
     // If the link is internal to the page (begins in #)  
     // then attach the smoothScroll function as an onclick  
     // event handler  
     ss_addEvent(lnk,'click',smoothScroll);  
   }  
 }  
}  
 
function smoothScroll(e) {  
 // This is an event handler; get the clicked on element,  
 // in a cross-browser fashion  
 if (window.event) {  
   target = window.event.srcElement;  
 } else if (e) {  
   target = e.target;  
 } else return;  
   
 // Make sure that the target is an element, not a text node  
 // within an element  
 if (target.nodeType == 3) {  
   target = target.parentNode;  
 }  
   
 // Paranoia; check this is an A tag  
 if (target.nodeName.toLowerCase() != 'a') return;  
   
 // Find the <a name> tag corresponding to this href  
 // First strip off the hash (first character)  
 anchor = target.hash.substr(1);  
 // Now loop all A tags until we find one with that name  
 var allLinks = document.getElementsByTagName('a');  
 var destinationLink = null;  
 for (var i=0;i<allLinks.length;i++) {  
   var lnk = allLinks[i];  
   if (lnk.name && (lnk.name == anchor)) {  
     destinationLink = lnk;  
     break;  
   }  
 }  
   
 // If we didn't find a destination, give up and let the browser do  
 // its thing  
 if (!destinationLink) return true;  
   
 // Find the destination's position  
 var destx = destinationLink.offsetLeft;    
 var desty = destinationLink.offsetTop;  
 var thisNode = destinationLink;  
 while (thisNode.offsetParent &&    
       (thisNode.offsetParent != document.body)) {  
   thisNode = thisNode.offsetParent;  
   destx += thisNode.offsetLeft;  
   desty += thisNode.offsetTop;  
 }  
   
 // Stop any current scrolling  
 clearInterval(ss_INTERVAL);  
   
 cypos = ss_getCurrentYPos();  
   
 ss_stepsize = parseInt((desty-cypos)/ss_STEPS);  
 ss_INTERVAL = setInterval('ss_scrollWindow('+ss_stepsize+','+desty+',"'+anchor+'")',10);  
   
 // And stop the actual click happening  
 if (window.event) {  
   window.event.cancelBubble = true;  
   window.event.returnValue = false;  
 }  
 if (e && e.preventDefault && e.stopPropagation) {  
   e.preventDefault();  
   e.stopPropagation();  
 }  
}  
 
function ss_scrollWindow(scramount,dest,anchor) {  
 wascypos = ss_getCurrentYPos();  
 isAbove = (wascypos < dest);  
 window.scrollTo(0,wascypos + scramount);  
 iscypos = ss_getCurrentYPos();  
 isAboveNow = (iscypos < dest);  
 if ((isAbove != isAboveNow) || (wascypos == iscypos)) {  
   // if we've just scrolled past the destination, or  
   // we haven't moved from the last scroll (i.e., we're at the  
   // bottom of the page) then scroll exactly to the link  
   window.scrollTo(0,dest);  
   // cancel the repeating timer  
   clearInterval(ss_INTERVAL);  
   // and jump to the link directly so the URL's right  
   location.hash = anchor;  
 }  
}  
 
function ss_getCurrentYPos() {  
 if (document.body && document.body.scrollTop)  
   return document.body.scrollTop;  
 if (document.documentElement && document.documentElement.scrollTop)  
   return document.documentElement.scrollTop;  
 if (window.pageYOffset)  
   return window.pageYOffset;  
 return 0;  
}  
 
function ss_addEvent(elm, evType, fn, useCapture)  
// addEvent and removeEvent  
// cross-browser event handling for IE5+,  NS6 and Mozilla  
// By Scott Andrew  
{  
 if (elm.addEventListener){  
   elm.addEventListener(evType, fn, useCapture);  
   return true;  
 } else if (elm.attachEvent){  
   var r = elm.attachEvent("on"+evType, fn);  
   return r;  
 }  
}    
 
var ss_INTERVAL;  
var ss_STEPS = 25;  
 
ss_addEvent(window,"load",ss_fixAllLinks);
Wrapping Up

Your document internal links will scroll to their destination, allowing your users to retain an awareness of where the browser is located within the document, and how far they are from their starting point. The code has been tested and works in Mozilla, IE, and Opera; it doesn't work in Konqueror, and is assumed to not work in other browsers.

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.

No Reader comments

Comments on this post are closed.