Last Updated: September 17, 2018
·
49.09K
· hasenj

Smooth Scrolling without jQuery

I think most people are content with just using jQuery for these kinds of tasks. Despite that, I still think this can be useful.

What this function does is change element.scrollTop over a period of time, so that at the end of the desired duration, it ends up at a desired target value.

The single advantage this might have over jQuery's animation is that it returns a promise that's fulfilled at the end of the animation, or rejected if the animation gets interrupted.

I'll explain the thinking behind it first then I'll show you the implementation.

Explanation

First off, to make the animation work, we need to know for each point in time, what we should set the value of scrollTop to.

Suppose the animation starts at time 3000 and ends at time 3500, and for this duration, the scrollTop needs to change from 140 to 250.

We are now at time 3120. What's the value for scrollTop?

If we were to do a simple linear interpolation, the math would be roughly like this:

interpolated_time = 120/500; // how much time since we started, over how much overall time
element.scrollTop = 140 + (110 * interpolated_time); // 110 being the distance we want to cover

Take a minute to make sure you understand where the math is coming from.

Now actually, this formula would mostly work, except the animation wouldn't look so natural. We need to create an easing effect, a simpler linear interpolation alone will not do.

I looked around for a bit and found this: http://en.wikipedia.org/wiki/Smoothstep

This is an interpolation function with smoothing/easing:

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

With this, calculating the scrollTop at a given time now is done like this:

var point = smooth_step(start_time, end_time, now);
var scrollTop = start_top + (distance * point);

This is mostly ok but it would return a floating point number. In the DOM you can't actually set the scrollTop value to a float; it will just round to an integer. So to avoid confusion, it's best that we round it as well:

var scrollTop = Math.round(start_top + (distance * point));

Also, I want the function to return a promise. This promise is fulfilled when we're done the animation, or rejected if the animation is interrupted.

How do we know that we got interrupted?

We keep track of where we think we're supposed to be if our animation is running happily, and if we're not in that place, then something is not working: probably the use scrolled differently, or another piece of code is trying to animate us in a different way. So, in this case, we just abort our animation and acknowledge the interruption.

It's also possible that the animation is not going according to how we want it to go without us being interrupted: if we reached the edge/limit and can't scroll any further. This is easy to detect: after we change scrollTop, we immediately check its value (during the same tick): if it hasn't changed to our desired value, that probably means it can't move anymore! I'm actually not sure if this works across all browsers, but it works in latest Chrome and Firefox, and that's good enough for me.

The implementation

/**
    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
 */
var smooth_scroll_to = 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 = Date.now();
    var end_time = start_time + duration;

    var start_top = element.scrollTop;
    var distance = target - start_top;

    // based on http://en.wikipedia.org/wiki/Smoothstep
    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 = Date.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);
    });
}

Usage examples

Find some web page with a long body, open the dev console and paste the function definition in it.

Then try the following:

smooth_scroll_to(document.body, 600, 2000);

In Chrome, this should smoothly scroll the body 600 pixels for 2 seconds.

Please note: for Firefox, you have to use document.documentElement.

Now let's try something more fun: chain several scrolling animations!

smooth_scroll_to(document.body, 1600, 2000).then(function() {
    return smooth_scroll_to(document.body, 800, 2000);
}).then(function() {
    return smooth_scroll_to(document.body, 3100, 2000);
}).catch(function(error) {
    console.log("Sequence cancelled:", error)
})

This would create a sequence of up and down animations, and when any one of them is interrupted, the entire sequence is also interrupted!

Thus is the power of Promises.

Try it: run the sequence, and manually scroll in the middle of it to interrupt it. The sequence will stop, and you will see the following message in the console:

Sequence cancelled: interrupted 

So there you have it. I hope this can be useful to you, or at least, I hope that you've learned something new today!

jsFiddle demo

7 Responses
Add your response

coll

over 1 year ago ·

Crazy! So tired of only seeing jquery while trying to implement some sort of smoothscroll js on our webpage for a project. This is great and thank you so much!

over 1 year ago ·

Does not work anymore...

over 1 year ago ·

There's currently an issue with ios devices, they currently fail when scrolling.
The culprit is this part:
// if(element.scrollTop != previoustop) {
// reject("interrupted");
// return;
// }
I haven't out how to fix, but the issue is that 'previous
top' variable is often not equal to 'element.scrollTop'. My gut feeling is that ios smooths scrolling by default, which would mean that the 'element.scrollTop' may move slightly by itself.

over 1 year ago ·

Thanks mate. You should use element.offsetTop instead of having a set number (e.g 800) in your target in your example. Would've been clearer.

over 1 year ago ·

Since I can't find a way to bookmark it, I gonna only say thank you so much! Vanilla is good!

over 1 year ago ·

I'd recommend looking up for scroll-behavior css property. It's working only on chrome at the moment but will be possible to do this without js on every browser in the future.

over 1 year ago ·