Improved Animated Scrolling Script for Same-Page Links

read 41 comments

After posting the last entry on animated scrolling with jQuery 1.2, I realized that I had left out an important piece of code. Actually, I didn't discover it until someone notified me that another page on the site was broken. Can you spot the problem(s)? [Note: the problem is not in line 3. The syntax highlighter just can't handle the regular expression with two slashes in it ("//") and is incorrectly treating them as a comment mark.] See the answer below the code.

JavaScript:
  1. $(document).ready(function(){
  2.   $('a[href*=#]').click(function() {
  3.     if (location.pathname.replace(/^\//,'') == this.pathname.replace(/^\//,'')
  4.     && location.hostname == this.hostname) {
  5.       var $target = $(this.hash);
  6.       $target = $target.length && $target
  7.       || $('[name=' + this.hash.slice(1) +']');
  8.       if ($target.length) {
  9.         var targetOffset = $target.offset().top;
  10.         $('html,body')
  11.         .animate({scrollTop: targetOffset}, 1000);
  12.        return false;
  13.       }
  14.     }
  15.   });
  16. });

Answer: The animated scrolling script hijacks links that look like this: <a href="#">. A couple people confirmed in the comments that the script needed a bit more work, so I figured we could take one more pass at it.

By the way, even though we attached the click event handler to all links that have the "#" symbol anywhere in the href, the very next line ensures that the link is pointing to the same page — by checking for a match between location.pathname and this.pathname — and the line after that ensures that it's pointing to the same domain, by checking for a match between location.hostname and this.hostname. With this approach, we can accommodate same-page links whether they include a fully-qualified URL, a relative URL, or just the fragment identifier.

Check for the Hash

Let's fix the problem with the <a href="#"> links. The first thing we have to do is see if there is actually something following the "#" symbol in the href. Apparently, if there is a lone "#" symbol, without any following characters, Firefox and Internet Explorer don't consider it a hash. Safari does, however. So, to avoid a false positive on <a href="#">, we need to first strip the "#" and then check if there is anything left. We can do so by adding this condition to the first if statement: && this.hash.replace(/#/,'')

Check for the Named Anchor

Since we're already changing the script, maybe it's a good time to make some of it more readable, too. This part with the "short-circuit" logic, using && and ||, makes me a little dizzy:

JavaScript:
  1. var $target = $(this.hash);
  2. $target = $target.length && $target
  3. || $('[name=' + this.hash.slice(1) +']');
  4. if ($target.length) {

There is absolutely nothing wrong with this syntax. In fact, more advanced JavaScripters use it all the time. But I feel more comfortable using a simpler, more straightforward style. So, let's set two variables — one for a target ID and one for a target named anchor. We'll then use conditional (aka ternary) operators to set a third, $target, variable as the target ID if it's there, and if not, the target named anchor if it's there, and if not, false. Then we can just check if $target has some value (other than false):

JavaScript:
  1. var $targetId = $(this.hash),
  2.   $targetAnchor = $('[name=' + this.hash.slice(1) +']');
  3. var $target = $targetId.length ? $targetId
  4.   : $targetAnchor.length ? $targetAnchor
  5.     : false;
  6. if ($target) {

Now it appears that the animated scrolling behavior will be attached to all same-page links and not break other stuff on the page.

Loop First, Bind Last

But there is another problem. Since we're still binding the .click() method to every link with "#" in it, even if it's appropriately avoiding applying the animation for some of those links, jQuery is still hijacking links that have an inline onclick handler (but, oddly, only the first time those links are clicked). To fix this problem, we can replace the .click() with .each(). Then we'll iterate through all links that have "#" somewhere in them, but place the conditions inside the loop so that we bind the click handler only after we've filtered out all the links that don't apply. Here is what the script looks like with the change:

JavaScript:
  1. $(document).ready(function() {
  2.   $('a[href*=#]').each(function() {
  3.     if (location.pathname.replace(/^\//,'') == this.pathname.replace(/^\//,'')
  4.     && location.hostname == this.hostname
  5.     && this.hash.replace(/#/,'') ) {
  6.       var $targetId = $(this.hash), $targetAnchor = $('[name=' + this.hash.slice(1) +']');
  7.       var $target = $targetId.length ? $targetId : $targetAnchor.length ? $targetAnchor : false;
  8.        if ($target) {
  9.          var targetOffset = $target.offset().top;
  10.          $(this).click(function() {
  11.            $('html, body').animate({scrollTop: targetOffset}, 400);
  12.            return false;
  13.          });
  14.       }
  15.     }
  16.   });
  17. });

Notice especially lines 2 and line 10. This change not only takes care of our problem, but it feels cleaner somehow, too. Is it more efficient? I don't know. Maybe someone else can tell us in the comments.

Normalize Directory Indexes

To be complete, we should probably take care of one more thing: the possibility that, on an "index" page, a link could point to "/path/index.htm" when the current location says "/path/" or vice versa. One way to "normalize" these index pages and links is to add a couple more .replace() methods to both sides of the equation in line 3.

Update

Aman suggested in a comment below that I make this process DRYer, and kangax provided a great example. So we can write a filter function and apply it to both sides rather than repeating the three replaces on each side:

JavaScript:
  1. function filterPath(string) {
  2.   return string
  3.     .replace(/^\//,'')  
  4.     .replace(/(index|default).[a-zA-Z]{3,4}$/,'')  // first additional replace
  5.     .replace(/\/$/,'');  // second additional replace
  6. }

The first additional .replace() will find a string represented by "index" or "default," followed by a dot, followed by any three or four letters at the end the pathname, and replace it with an empty string (i.e. remove it). The second one will replace a trailing slash with an empty string. As with chained jQuery methods, these regular-expression methods can be placed on separate lines to improve readability. Finally, we have a bullet-proof (I hope) animated scrolling script for same-page links:

JavaScript:
  1. $(document).ready(function() {
  2.   function filterPath(string) {
  3.     return string
  4.       .replace(/^\//,'')  
  5.       .replace(/(index|default).[a-zA-Z]{3,4}$/,'')  
  6.       .replace(/\/$/,'');
  7.   }
  8.   $('a[href*=#]').each(function() {
  9.     if ( filterPath(location.pathname) == filterPath(this.pathname)
  10.     && location.hostname == this.hostname
  11.     && this.hash.replace(/#/,'') ) {
  12.       var $targetId = $(this.hash), $targetAnchor = $('[name=' + this.hash.slice(1) +']');
  13.       var $target = $targetId.length ? $targetId : $targetAnchor.length ? $targetAnchor : false;
  14.        if ($target) {
  15.          var targetOffset = $target.offset().top;
  16.          $(this).click(function() {
  17.            $('html, body').animate({scrollTop: targetOffset}, 400);
  18.            return false;
  19.          });
  20.       }
  21.     }
  22.   });
  23. });

If you try it out, let me know how it goes.

Update 2

Ariel Flesler has written an excellent ScrollTo plugin, which he says was inspired by this blog entry. Be sure to check out the demo.

Update 3

Someone called my attention to a problem that this script was having in IE and Opera. Not sure how I could have missed that, because I'm sure I tested it in both of those browsers. But never mind, I've come up with a little patch:

JavaScript:
  1. $(document).ready(function() {
  2.   function filterPath(string) {
  3.   return string
  4.     .replace(/^\//,'')
  5.     .replace(/(index|default).[a-zA-Z]{3,4}$/,'')
  6.     .replace(/\/$/,'');
  7.   }
  8.  
  9.   var locationPath = filterPath(location.pathname);
  10.   $('a[href*=#]').each(function() {
  11.     var thisPath = filterPath(this.pathname) || locationPath;
  12.     if (  locationPath == thisPath
  13.     && (location.hostname == this.hostname || !this.hostname)
  14.     && this.hash.replace(/#/,'') ) {
  15.       var $target = $(this.hash), target = this.hash;
  16.       if (target) {
  17.         var targetOffset = $target.offset().top;
  18.         $(this).click(function(event) {
  19.           event.preventDefault();
  20.           $('html, body').animate({scrollTop: targetOffset}, 400, function() {
  21.             location.hash = target;
  22.           });
  23.         });
  24.       }
  25.     }
  26.   });
  27. });

Apparently, IE doesn't see a hostname or pathname if a link's href attribute is set with JavaScript and contains only a hash (such as "#example"). So, I'm checking now for either a match or an absence of hostname and pathname.

I hope this change fixes the problem that Mike was having. Seems to work in my tests now. Oh, and I took the opportunity to improve the code a bit. Now, it has mild back-button support: while clicking on the back button doesn't produce the animated scrolling, it at least gets you back to the previous location.

comment feed

41 comments

  1. Aman Gupta

    Lines 4-6 are the same as lines 8-10. Surely there's a DRYer solution?

  2. Hi Aman,

    I'm not sure if there is a DRYer solution, because both location.pathname and this.pathname need to be run through the three replace methods in order to account for the possibility on an "index" page that the current URL and the link's href are represented differently: with or without a file name, such as index.html, and with or without the trailing slash. The first replace accounts for browser differences in including an initial slash in the pathname.

    Also, as I imply in the entry, lines 3 - 10 can be written on a single line, so maybe it just looks like more is going on than there actually is. If you can think of something DRYer and better, though, please let me know. I'm always eager to learn.

  3. Marcus T

    Great stuff! However, it's surprising you didn't provide an optional parameter to specify an easing algorithm other than the default linear. I've added it myself but perhaps you might want to do the same to your code published above.

  4. Hi Marcus,

    Sorry about that. It's really easy to use easing with the .animate() method. Just include an easing plugin and then add in the easing type as a parameter to .animate(). Something like this:

    $('html, body').animate(
      {scrollTop: targetOffset},
      {duration: 400, easing: 'easeInOutExpo'}
    );

    You can also do the same thing with slightly different syntax, like this:

    $('html, body').animate(
      {scrollTop: targetOffset}, 400, 'easeInOutExpo'
    );
  5. Verrrry good to Karl.. I don't know if you saw, but I made a plugin, inspired on your post. To scroll the window and overflowed elements as well... I pulled out an implementation of your "same-page-links-scrolling" using the plugin as someone asked for that. I must say all the credit goes to you ;)

  6. You could make the replacement to the page URI once and store it in a variable, then use jQuery.fn.filter with a function, and only to those passing the filter, apply the click. I think that might look cleaner.

  7. I might be failing to see something, but what's up with this "replace" repetition?

    Why not define a helper "filter" function to keep it "DRY" as Aman pointed out.

    
    $(document).ready(function() {
       function filter(string) {
          return string
             .replace(/^\//,'')
             .replace(/(index|default)\.[a-zA-Z]{3,4}$/,'')
             .replace(/\/$/,'')
       }
       $('a[href*=#]').each(function() {
          if (filter(location.pathname) == filter(this.pathname)
             && location.hostname == this.hostname
    	 && this.hash.replace(/#/,'') ) {
    	    var $targetId = $(this.hash), $targetAnchor = $('[name=' + this.hash.slice(1) +']');
    	    var $target = $targetId.length ? $targetId : $targetAnchor.length ? $targetAnchor : false;
    	    if ($target) {
    	       var targetOffset = $target.offset().top;
    	       $(this).click(function() {
    	          $('html, body').animate({scrollTop: targetOffset}, 400);
    	          return false;
    	        });
                }
             }
          });
       });
    
    
  8. Thanks a lot, Kangax! That makes a lot of sense. I'll update the entry to include your function.

  9. I added this snippet of code, and it worked fine in IE and FF.

    function samePage( link ){
    return location.href.replace(location.hash,'') == link.href.replace(link.hash,'');
    };

    return true or false.

  10. Hi,

    I've read all your posts relating to scrolling and I understand how it can scroll the page within the browser but I was wondering is there a way to modify it so that I can present a list of links above a "div" section that has an overflow set to scroll and animate the scrolling of this div?

    Thanks in Advance.

  11. Hi Matthew,

    Here is the code I demonstrated in the previous animated-scrolling tutorial. It triggers the scrolling from a single button to a specified place within the scrollable element, but changing it to a list o links triggering a scroll to multiple places within the div should be trivial.

    $(document).ready(function() {
      $('#scrollit').click(function() {
        var divOffset = $('#scrollable').offset().top;
        var pOffset = $('#scrollable p:eq(2)').offset().top;
        var pScroll = pOffset - divOffset;
        $('#scrollable').animate({scrollTop: '+=' + pScroll + 'px'}, 1000, 'bounceout');
      });
    });

    Note, you'll need to use an easing plugin with this example. If you'd rather not have an easing effect, just remove , 'bounceout' from the sixth line.

  12. Follow up to my last post:

    I applied ".parent()" to the click function to animate the div. It works great in FF2 but IE6 put the focus just below the section. Any idea's on how to fix this?

    Also I've pasted the entire code, for your reference, at: http://pastemonkey.org/paste/47261c71-1fa8-4ce9-b09d-2493404fdb0d

    
    <script type="text/javascript" src="/js/jquery/jquery-1.2.1.pack.js"></script>
    <script type="text/javascript">
    $(document).ready(function() {
      function filterPath(string) {
    	return string
    	  .replace(/^\//,'')
    	  .replace(/(index|default).[a-zA-Z]{3,4}$/,'')
    	  .replace(/\/$/,'');
      }
      $('a[href*=#]').each(function() {
    		if ( (filterPath(location.pathname) == filterPath(this.pathname))
    		&& (location.hostname == this.hostname)
    		&& (this.hash.replace(/#/,'')) ) {
    			var $targetId = $(this.hash), $targetAnchor = $('[name=' + this.hash.slice(1) +']');
    			var $target = $targetId.length ? $targetId : $targetAnchor.length ? $targetAnchor : false;
    			 if ($target) {
    			 var targetOffset = $target.offset().top;
    			 $(this).click(function() {
    				 $target.parent().animate({scrollTop: targetOffset}, 500);
    				 return false;
    			 });
    			}
    		}
      });
    });
    </script>
    
  13. Does this work (in your browsers)?

    
    $(document).ready(function() {
      function filterPath(string) {
    	return string
    	  .replace(/^\//,'')
    	  .replace(/(index|default).[a-zA-Z]{3,4}$/,'')
    	  .replace(/\/$/,'');
      }
      $('a[href*=#]').each(function() {
    		if ( (filterPath(location.pathname) == filterPath(this.pathname))
    		&& (location.hostname == this.hostname)
    		&& (this.hash.replace(/#/,'')) ) {
    			var $targetId = $(this.hash), $targetAnchor = $('[name=' + this.hash.slice(1) +']');
    			var $target = $targetId.length ? $targetId : $targetAnchor.length ? $targetAnchor : false;
    			 if ($target) {
    			 var divOffset = $target.parent().offset().top;
    			 var pOffset = $target.offset().top;
    			 var pScroll = pOffset - divOffset;
    			 $(this).click(function() {
    				 $target.parent().animate({scrollTop: pScroll + 'px'}, 500);
    				 return false;
    			 });
    			}
    		}
      });
    });
    

    Full Code at: http://pastemonkey.org/paste/37

  14. Yes, it works for me in FF 2 Mac and IE 6 Windows. Try it here: http://test.learningjquery.com/matthew.html

  15. By the way, I used $('a[hash]') in localScroll and it worked fine in IE, Opera and FF that I tested.
    Seems like href="#" gives empty string as hash, so even better!

  16. Millhouse

    This script works great, thank you for it!

    How could it be modified to show the clicked URL in the address bar?

  17. Hi Millhouse,

    Ariel Flesler has taken this script, converted it into a plugin, and improved upon it further. He just announced on the jQuery discussion list that the plugin can now show the clicked URL in the address bar. Take a look.

  18. Cheers mate -works neatly for me...
    Thanks for this and happy xmas.!

  19. Andy

    This gives problems in Opera, using jQuery 1.2.1 and Opera 9.23.
    Some of the links work, other just don't go to the anchor, or got to the absolute top of the page.

    It works well in FF (2 and even 0.7) and IE 7.

  20. Ty (tzmedia)

    Ahhh, Karl, just the man I am looking for...
    Have you seen the buzz around this new teaser site for:
    http://silverbackapp.com/
    The leaves use a parallax scrolling alignment effect when windows width is resized.
    Some parallax backgrounds along with easing effects, would be just killer with this very cutting-edge technique you have going here!!
    Searching the jQ user groups and to my surprise, I couldn't find any discussion at all of parallax scrolling.

  21. me too .. my client wants to work on opera and safari i really dont know why because the percentage of the user who use those browser are very small

  22. You are not unleashing the power of Jquery

  23. This is nice information though i am looking for css codes to display 5 links in same page. when a user click on these link they don't live that page and comes up the page ins the same page let me know please..

  24. Not sure what you mean by "css codes," but to have a link fetch information from another page and update the current page with it (without refreshing), you should look at jQuery's ajax methods. I think you'll find the load() method particularly helpful.

  25. abelafonte

    Is there a way to hilight the selected trigger once your scrolling stops?

  26. Gregory

    i tried to use this script to put a link in my footer to scroll back to the top of the page.
    clicking the link the first time effectively scrolls the page up, however scrolling the page down and clicking the link again has no effect.

    any explanation ?
    thx in advance

  27. Great work on this script, it's done really elegantly.

    I've got a large site that uses plenty of href="#" for 'back-to-top' links - However, with this updated script it won't animate back to the top without an explicit target id or name.

    Does anyone have any idea how I might get it to work in this scenario?

    James

  28. Just wanted to say how good the code is.

  29. axl

    Hi, I was looking for a way to scroll down to a certain div when the mouse hover an image. I wonder if this could be made using this logic. It's my first time using jQuery and I'm not even sure if this can be made and where to start.

    thanks for your help.

  30. Mike

    Hi, Karl! Nice work, now it works in IE7 too. Although, putting some *.png (even with pngfix script) in the page top (like header), slows down the script and near to the header it acts not so smooth, but that's IE problem I guess. In Opera, scrolling still doesn't work.

  31. Hi. I'm using this scroller in my work-in-progress wordpress theme and it's nice since it doesn't hijack other js, but I'm getting an error in Safari's inspector at line 17 and the scrolling only works in the downward direction for my two uppermost links. Any ideas? My site.

  32. Hi tstorm,

    Oddly enough, when I copied the html from your site and put it on my test server, it worked fine. Well, almost. The scrolling doesn't make it all the way down to the correct element. But I wonder if that might have to do with the HTML. Looks like it has some validation issues. Also, although Safari didn't throw the error you mentioned (on my site), it didn't like this inline onclick function: onclick="someFunc();return false". Not sure why that's in there, but it might be conflicting.

  33. mike

    Is there anyway to make this also scroll to a point when a new page is called?

    so like
    /categories#shirts
    when you are coming from
    /home

    load up the new page and then scroll to it?

  34. Hi
    First thank you very much for a great tutorial my only problem is I don't seem to be able to make it scroll back to higher position using your piece of code :
    var targetOffset = $target.offset().top - 100;
    it just doesn't do anything in Firefox 3.0.3 as I havn't tested any other browser so far.
    Any ideas why ???
    Thanks for help
    Jean from France

  35. Hmm. That is weird that it's working perfectly on your test site. I got rid of that onclick function, which was a leftover from a different scroller I had tried, and I touched up the html a bit. But I'm still having the same problems with the scroller. I've been wracking my brain for what could account for the difference between your server and mine. No idea. But thanks for your help. Your scripts are the best.

  36. Hi, thanks for this technique; it works very well on almost every browser.. For some reason, the script fails in Google Chrome (version 0.2.149.30). Instead of hitting the target, it gets lost, frequently scrolling in the opposite direction. I haven’t tested all of the specifics, but there is definitely an issue here..

  37. Adeel Shahid

    a note from site too :)

    i added to the very top of the document right after the body and then placed links for Top inside the page so that by click the link we can animatically go to the top of the page, but it goes to the top with only the bottom of the link test link just the slightest bit showing, so what i did is i placed,

    inside the body tag, which can also be done programatically now the correct behaviour to going to top work, tested in Firefoxe

  38. I have what seems to be an odd request. I am building a very tall page and I want the nav to smooth scroll down to the anchors. This script works great for normal text and html button links...but my nav is flash based and I can't get it to smooth scroll.

    If I use this action script on the flash button:
    getURL("#anchor");
    it just jumps like normal and doesn't smooth scroll

    I know I can call a javascript with a flash button with this action script:
    getURL("javascript:somefunction(something);");

    But I can't figure out what "somefunction" and "something" to use in order to make the javascript work. I assume "something" would have to be my anchor (ex: "#anchor") right? But what about the "somefunction"

    I know the flash works I just don't know what from the javascript code needs to be put into the button

    Thanks in advance.

  39. alan

    @steve,

    Since no one else has answered you and in case you've not found the answer yourself here's my take on it - take Karl's code and turn it into a plain function rather than binding it to <a> click events like this:

    
    $(document).ready(function() {
      function scrollTo(scrollToId) {
        var $target = $('#' + scrollToId);
        var targetOffset = $target.offset().top;
        $('html, body').animate({scrollTop: targetOffset}, 400);
      };
    });
    

    Which is much simpler because we know the code is called with an id to an element on this page and so we don't need all the tests for different types of links.

    So if you had mark up like this:

    
    <object src="flash"></object>
    some text
    <div id="scrollTo1">text for 1st scroll to position</div>
    <div id="scrollTo2">text for 2nd scroll to position</div>
    <div id="scrollTo2">text for 3rd scroll to position</div>
    

    then your actionscript calls would be:
    getURL("javascript:scrollTo('scrollTo1');");
    and
    getURL("javascript:scrollTo('scrollTo2');");
    and
    getURL("javascript:scrollTo('scrollTo3');");

    Hope that helps,

    Alan.

  40. ashish

    if(selectDivId!=null)
    {
    var divOffset = $('#r320').offset().top;
    var jquerySelectedDivId='#'+selectDivId+'';
    var pOffset =$(jquerySelectedDivId).offset().top;
    var pScroll = pOffset - divOffset;
    }
    else
    var pScroll=0;
    $('#r320').animate({scrollTop: '+=' + pScroll + 'px'}, 1000);

    });

    I am using div to div scrolling instead of link on same page...Nut this thing doesnt wrk on IE, pls help

2 Pings

  1. [...] I've posted a new entry about how to achieve the same effect (and more) using jQuery 1.2, without the need for any of the Interface plugin modules: Animated Scrolling with jQuery 1.2. [Posted an improved version Oct. 20, 2007] [...]

  2. [...] Learning jQuery » Improved Animated Scrolling Script for Same-Page Links (tags: jquery scroll scrolling animation animated javascript webdev webdesign web development design) [...]

Leave a Comment

IMPORTANT:

  • If you wish to post code examples, please wrap them in <code> tags.
  • Multi-line code should be wrapped in <pre><code> </code></pre>
  • Use &lt; instead of < and &gt; instead of > in the examples themselves. Otherwise, you could lose part of the comment when it's submitted.