Wibbly Stuff

Smooth scroll for in page links with CSS3 transitions

For past days, I'm avoiding jQuery animate like crazy, and try to use CSS3 animations and transitions wherever possible. Reason? CSS3 animations and transitions usually give a higher frame rate than jQuery animations and feel much smoother.

In-page links usually jump to the anchor position, and feel rude. Scrolling to the anchor position with animation feels more natural. Since I found most of the solutions on the web to be based on jQuery animate, I tried to redo this in CSS3 for use in numixproject.org.

So, let's see what we have. I got the following snippet using jQuery animate for smooth scrolling from the ever awesome CSS Tricks, and it works great, but it's not CSS3 :(

$(function() {
  $('a[href*=#]:not([href=#])').click(function() {
    if (location.pathname.replace(/^\//,'') == this.pathname.replace(/^\//,'') && location.hostname == this.hostname) {
      var target = $(this.hash);
      target = target.length ? target : $('[name=' + this.hash.slice(1) +']');
      if (target.length) {
        $("html, body").animate({
          scrollTop: target.offset().top
        }, 1000);
        return false;
      }
    }
  });
});

Let's modify it to use CSS3 transitions. Shall we? Note that we'll still use jQuery (not jQuery animate). While this could be written using pure javascript, jQuery is simpler, and everyone uses it :D

For the browsers that don't have CSS3 transitions, we'll fallback to jQuery animate. First we'll check for CSS3 transitions support. I'm using a basic approach for the check, but you can use Modernizr for more efficient checks.

if (typeof document.body.style.transitionProperty === 'string') {

    // CSS3 transitions are supported. Yay!

} else {
    $("html, body").animate({
        scrollTop: target.offset().top
    }, 1000);
    return;
}

So, first, we set a negative margin, and transition it to "0" at a time interval. We also need to consider how much the user has scrolled to avoid overscrolling. Here, we are using $("html") instead of $(body) to prevent style conflicts.

We also need to prevent default scroll action since we are emulating it with margin change, unlike jQuery animate.

e.preventDefault();
$("html").css({
    "margin-top" : ( $(window).scrollTop() - target.offset().top ) + "px",
    "transition" : "1s ease-in-out"
});

But what if the available space is less than the scroll area? It'll cause a jumping behavior. So we need to fix it.

var avail = $(document).height() - $(window).height();
    scroll = target.offset().top;

if (scroll > avail) {
    scroll = avail;
}

$("html").css({
    "margin-top" : ( $(window).scrollTop() - scroll ) + "px",
    "transition" : "1s ease-in-out"
});

When the transition ends, we remove extra margin and styles. Also, after we remove the properties, we need to scroll to the position again so that it doesn't jump back to top. Don't worry, it won't be visible.

We can use the transitionend event to detect when the transition ends.

$("html").on("transitionend webkitTransitionEnd msTransitionEnd oTransitionEnd", function () {
    $(this).removeAttr("style").data("transitioning", false);
    $("html, body").scrollTop(scroll);
});

While this should be sufficient, we also need to prevent the transitionend event from being fired when any other transition on the page finishes. So we'll add some safeguards.

We will set a HTML5 data attribute when the transition starts, then on transitionend event, we'll check for it and if it is true, we'll do the rest.

Also, to prevent the child elements from triggering transitionend, we'll have the check (e.target == e.currentTarget)

$("html").on("transitionend webkitTransitionEnd msTransitionEnd oTransitionEnd", function (e) {
    if (e.target == e.currentTarget && $(this).data("transitioning") === true) {
        $(this).removeAttr("style").data("transitioning", false);
        $("html, body").scrollTop(scroll);
    }
});

So that's it. Let's wrap it up :)

// Smooth scroll for in page links
$(function(){
    var target, scroll;

    $("a[href*=#]:not([href=#])").on("click", function(e) {
        if (location.pathname.replace(/^\//,'') == this.pathname.replace(/^\//,'') && location.hostname == this.hostname) {
            target = $(this.hash);
            target = target.length ? target : $("[id=" + this.hash.slice(1) + "]");

            if (target.length) {
                if (typeof document.body.style.transitionProperty === 'string') {
                    e.preventDefault();
                  
                    var avail = $(document).height() - $(window).height();

                    scroll = target.offset().top;
                  
                    if (scroll > avail) {
                        scroll = avail;
                    }

                    $("html").css({
                        "margin-top" : ( $(window).scrollTop() - scroll ) + "px",
                        "transition" : "1s ease-in-out"
                    }).data("transitioning", true);
                } else {
                    $("html, body").animate({
                        scrollTop: scroll
                    }, 1000);
                    return;
                }
            }
        }
    });

    $("html").on("transitionend webkitTransitionEnd msTransitionEnd oTransitionEnd", function (e) {
        if (e.target == e.currentTarget && $(this).data("transitioning") === true) {
            $(this).removeAttr("style").data("transitioning", false);
            $("html, body").scrollTop(scroll);
            return;
        }
    });
});

You can just throw the snippet in your page, and it should work. If you experience any issues, feel free to comment here. I'll love to hear feedback and try my best to fix bugs with the snippet.

See it in action