O'Reilly Network    
 Published on O'Reilly Network (http://www.oreillynet.com/)
 http://www.oreillynet.com/pub/a/network/2005/08/26/scrolllinksinJS.html
 See this if you're having trouble printing code examples


Make Internal Links Scroll Smoothly with JavaScript

by Stuart Langridge, author of DHTML Utopia (SitePoint)
08/26/2005

When they're navigating through a long document, users often are confused or disoriented when they click on 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, as you can see from this demo. In this tutorial, we'll use a smattering of JavaScript to ensure that links that are internal to the document scroll users to their destinations, rather than jumping straight there and confusing site visitors.

Finding Internal Links

First, we need to identify all the links in the document, and work out which of them are internal links. 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 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 current document, 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
   }
 }

The for loop walks through the list of links in the document; we check three things:

  1. Does the link contain a hash symbol? To check this, we use 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 query string the same as the current location? The query string 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 query string.

If the answer to each of these questions is yes, then we know the link is an internal one, and we can set it to scroll to its destination rather than simply navigate there.

DHTML Utopia

Related Reading

DHTML Utopia
Modern Web Design Using JavaScript and DOM
By Stuart Langridge


Read Online--Safari
Search this book on Safari:
 

Code Fragments only

Scroll, Don't Jump!

Now that we've identified an internal link, we want to scroll to it 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 that event handlers should be set on a link within the HTML:


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

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 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;
 }
}

As we loop over the links, each matching link must have a new event handler attached to it for the "click" event. Then, when the link is clicked, it calls our smoothScroll() function instead of just navigating to the link as the browser normally would:


ss.addEvent(lnk,'click',ss.smoothScroll);

How to Scroll

Of course, we have to 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 achieve this in various ways. The marvelous Andrew Clover has written a summary of the process that's used to find this position across browsers; we'll use this solution extensively here.

First, our smoothScroll() function is an event handler, so when it's called (that is, 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 item. We need to check whether or not the target is a text node (that is, 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 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 of the links in the document and check whether their name attributes are equal to the hash part of the clicked 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, and what that points to. Now, all we need to know is our location within 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, we work out where we are currently located:


function 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 handle the scrolling is to use setInterval(); this very 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 variable in which we will later store the ouput of setInterval(). Next, we must 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:

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 from taking its normal course by obeying the link and jumping directly to the destination. Again, this process occurs differently in different browsers. To stop the browser handling this event normally in Internet Explorer, use the following code:


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

Notice the check for window.event, which ensures 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 X and Y positions; 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 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

To apply this effect to a page, simply put the JavaScript code in a file called smoothscroll.js and include that file in your HTML page's HEAD as follows:


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

That's all you need to do. This approach follows the principles of unobtrusive DHTML, making it easy for everyone to use. The script starts itself up when the page is loaded, by attaching itself to the page's "load" event:


ss.addEvent(window,"load",ss.fixAllLinks);

The complete code looks like this:


/* Smooth scrolling
   Changes links that link to other parts of this page to scroll
   smoothly to those links rather than jump to them directly, which
   can be a little disorienting.
   
   sil, http://www.kryogenix.org/
   
   v1.0 2003-11-11
   v1.1 2005-06-16 wrap it up in an object
*/

var ss = {
  fixAllLinks: function() {
    // 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',ss.smoothScroll);
      }
    }
  },

  smoothScroll: function(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();
    }
  },

  scrollWindow: function(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;
    }
  },

  getCurrentYPos: function() {
    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;
  },

  addEvent: function(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;
    } else {
      alert("Handler could not be removed");
    }
  } 
}

ss.STEPS = 25;

ss.addEvent(window,"load",ss.fixAllLinks);

Wrapping Up

Your document's internal links will scroll to their destination, allowing your users to retain an awareness of the browser's location within the document, and how far they are from their starting point.

Stuart Langridge is an information architect for a law firm in the UK. He writes about JavaScript and DHTML, as well as pretty much anything else that catches his attention, at www.kryogenix.org.


Return to the O'Reilly Network

Copyright © 2007 O'Reilly Media, Inc.