tl;dr? Click here.

Momentum – or inertial – based scrolling is one of the fairly ‘new’ phenomena introduced by touch devices in the last few years. Compared with a regular (linear) scroll, momentum scrolling offers a more natural way of scrolling that is easier on the eye and a lot more graceful. By default, momentum scrolling is used in most touch-based devices when scrolling webpages that extend past edges of your browser’s screen (a.k.a. overflowing content). In webkit browsers, the effect can also be ‘activated’ on elements having the css properties overflow: auto; or overflow: scroll; by setting a webkit specific css property: webkit-overflow-scrolling: touch;. However, all this is in vain when you want to apply it to stuff like a <canvas> or if you hook into touch events and need to use preventDefault(); when handling them.

I was frustrated by this when creating a simple web-app base layer recently. It hooked into touch events (using a library, TouchSwipe) to enable horizontal swiping, or navigating, within the webapp. For vertical swiping, I wanted my content to scroll. I could have just detected a vertical scroll, and set a stopPropagation(); to resume default browser behavior and enabling momentum scrolling, but that did not cut it: I wanted to only scroll the element I wanted to scroll, and leave other things like the body and document static; if the user would swipe on anything but scrollable elements, I wanted my content to stay in place (by default, a rubber band effect will occur when scrolling past the end of a page for example, I did not want that to happen). So I bound the vertical touchmove events to do a scrollTop on the element so it would scroll. Happy times; this worked fine.

Hoewever, this did not look as pretty as the momentum scrolling that most touch-based devices produce. As I could not enable this native behaviour (see above), I needed to add some kind of simulated momentum scrolling. This was a lot more work than I expected. I first tried out various (pre-built) solutions by others, like Droidscroll and jQuery.kinetic, but they did not meet my demands (mainly, they either did ‘too much’, broke the rest of my scripts by hijacking the touch events or did both), so I had to ‘reinvent’ momentum scrolling myself.

The idea

I have tried numerous methods for simulating momentum scrolling, and after a fair amount of experiments finally settled on the following principle, based on three touch events:

  1. touchstart – get the coordinates of the touch event and store them, create a timer for the start time
  2. touchmove – calculate distance by comparing the coordinates of the touch event to the coordinates stored on touchstart and calculate acceleration (or speed) of the touch movement by dividing distance by (current time – start time). In the meantime scroll the element.
  3. touchend – calculate touchtime by comparing the current time to the last touchmove time, calculate offset by taking the acceleration to the power of 2, times the window height or the scrolling element’s width, depending on direction. Then, scroll the element for an extra amount of pixels (offset), using quartic ‘out’ easing (which seems to best match the native momentum scrolling curve), for a pre-set duration.

To my amazement, this produced very nice results, and after some tweaking I managed to get pretty close to native momentum scrolling. At least; good enough for me.

Demo

Click here for a demo, or scroll the code example further below. Be sure to view it on a touch-enabled device (or use debug tools to override and emulate touch with your mouse, like in the Chrome developer tools).

Code

Below is the full annotated code for the inertial scroller at the time of writing. Paste it into a .js file and include it in your page. Then set the elements that need to scroll to have the class ‘inertialScroll’ (or modify the code to accept another class). The code should be non-unobtrusive and thus should not wreck any existing (touch based) handlers (but I am by no means a jQuery expert, so try it out for yourself). Feel free to modify and adapt the code (a mention would of course be nice)

/**
 * jQuery Inertial Scroller v1.5
 * (c)2013 hnldesign.nl
 * This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/.
 **/
/*jslint browser: true*/
/*global $, jQuery*/

/* SETTINGS */
var i_v = {
    i_touchlistener     : '.inertialScroll',         // element to monitor for touches, set to null to use document. Otherwise use quotes. Eg. '.myElement'. Note: if the finger leaves this listener while still touching, movement is stopped.
    i_scrollElement     : '.inertialScroll',         // element (class) to be scrolled on touch movement
    i_duration          : window.innerHeight * 1.5, // (ms) duration of the inertial scrolling simulation. Devices with larger screens take longer durations (phone vs tablet is around 500ms vs 1500ms). This is a fixed value and does not influence speed and amount of momentum.
    i_speedLimit        : 1.2,                      // set maximum speed. Higher values will allow faster scroll (which comes down to a bigger offset for the duration of the momentum scroll) note: touch motion determines actual speed, this is just a limit.
    i_handleY           : true,                     // should scroller handle vertical movement on element?
    i_handleX           : true,                     // should scroller handle horizontal movement on element?
    i_moveThreshold     : 100,                      // (ms) determines if a swipe occurred: time between last updated movement @ touchmove and time @ touchend, if smaller than this value, trigger inertial scrolling
    i_offsetThreshold   : 30,                       // (px) determines, together with i_moveThreshold if a swipe occurred: if calculated offset is above this threshold
    i_startThreshold    : 5,                        // (px) how many pixels finger needs to move before a direction (horizontal or vertical) is chosen. This will make the direction detection more accurate, but can introduce a delay when starting the swipe if set too high
    i_acceleration      : 0.5,                      // increase the multiplier by this value, each time the user swipes again when still scrolling. The multiplier is used to multiply the offset. Set to 0 to disable.
    i_accelerationT     : 300                       // (ms) time between successive swipes that determines if the multiplier is increased (if lower than this value)
};
/* stop editing here */

//set some required vars
i_v.i_time  = {};
i_v.i_elem  = null;
i_v.i_elemH = null;
i_v.i_elemW = null;
i_v.multiplier = 1;

// Define easing function. This is based on a quartic 'out' curve. You can generate your own at http://www.timotheegroleau.com/Flash/experiments/easing_function_generator.htm
if ($.easing.hnlInertial === undefined) {
    $.easing.hnlInertial = function (x, t, b, c, d) {
        "use strict";
        var ts = (t /= d) * t, tc = ts * t;
        return b + c * (-1 * ts * ts + 4 * tc + -6 * ts + 4 * t);
    };
}

$(i_v.i_touchlistener || document)
    .on('touchstart touchmove touchend', function (e) {
        "use strict";
        //prevent default scrolling
        e.preventDefault();
        //store timeStamp for this event
        i_v.i_time[e.type]  = e.timeStamp;
    })
    .on('touchstart', function (e) {
        "use strict";
        this.tarElem = $(e.target);
        this.elemNew = this.tarElem.closest(i_v.i_scrollElement).length > 0 ? this.tarElem.closest(i_v.i_scrollElement) : $(i_v.i_scrollElement).eq(0);
        //dupecheck, optimizes code a bit for when the element selected is still the same as last time
        this.sameElement = i_v.i_elem ? i_v.i_elem[0] == this.elemNew[0] : false;
        //no need to redo these if element is unchanged
        if (!this.sameElement) {
            //set the element to scroll
            i_v.i_elem = this.elemNew;
            //get dimensions
            i_v.i_elemH = i_v.i_elem.innerHeight();
            i_v.i_elemW = i_v.i_elem.innerWidth();
            //check element for applicable overflows and reevaluate settings
            this.i_scrollableY      = !!((i_v.i_elemH < i_v.i_elem.prop('scrollHeight') && i_v.i_handleY));
            this.i_scrollableX    = !!((i_v.i_elemW < i_v.i_elem.prop('scrollWidth') && i_v.i_handleX));
        }
        //get coordinates of touch event
        this.pageY      = e.originalEvent.touches[0].pageY;
        this.pageX      = e.originalEvent.touches[0].pageX;
        if (i_v.i_elem.is(':animated') && (i_v.i_time.touchstart - i_v.i_time.touchend) < i_v.i_accelerationT) {
            //user swiped while still animating, increase the multiplier for the offset
            i_v.multiplier += i_v.i_acceleration;
        } else {
            //else reset multiplier
            i_v.multiplier = 1;
        }
        i_v.i_elem
            //stop any animations still running on element (this enables 'tap to stop')
            .stop(true, false)
            //store current scroll positions of element
            .data('scrollTop', i_v.i_elem.scrollTop())
            .data('scrollLeft', i_v.i_elem.scrollLeft());
    })
    .on('touchmove', function (e) {
        "use strict";
        //check if startThreshold is met
        this.go = (Math.abs(this.pageX - e.originalEvent.touches[0].pageX) > i_v.i_startThreshold || Math.abs(this.pageY - e.originalEvent.touches[0].pageY) > i_v.i_startThreshold);
    })
    .on('touchmove touchend', function (e) {
        "use strict";
        //check if startThreshold is met
        if (this.go) {
            //set animpar1 to be array
            this.animPar1 = {};
            //handle events
            switch (e.type) {
            case 'touchmove':
                this.vertical       = Math.abs(this.pageX - e.originalEvent.touches[0].pageX) < Math.abs(this.pageY - e.originalEvent.touches[0].pageY); //find out in which direction we are scrolling
                this.distance       = this.vertical ? this.pageY - e.originalEvent.touches[0].pageY : this.pageX - e.originalEvent.touches[0].pageX; //determine distance between touches
                this.acc            = Math.abs(this.distance / (i_v.i_time.touchmove - i_v.i_time.touchstart)); //calculate acceleration during movement (crucial)
                //determine which property to animate, reset animProp first for when no criteria is matched
                this.animProp       = null;
                if (this.vertical && this.i_scrollableY) { this.animProp = 'scrollTop'; } else if (!this.vertical && this.i_scrollableX) { this.animProp = 'scrollLeft'; }
                //set animation parameters
                if (this.animProp) { this.animPar1[this.animProp] = i_v.i_elem.data(this.animProp) + this.distance; }
                this.animPar2       = { duration: 0 };
                break;
            case 'touchend':
                this.touchTime      = i_v.i_time.touchend - i_v.i_time.touchmove; //calculate touchtime: the time between release and last movement
                this.i_maxOffset    = (this.vertical ? i_v.i_elemH : i_v.i_elemW) * i_v.i_speedLimit; //(re)calculate max offset
                //calculate the offset (the extra pixels for the momentum effect
                this.offset         = Math.pow(this.acc, 2) * (this.vertical ? i_v.i_elemH : i_v.i_elemW);
                this.offset         = (this.offset > this.i_maxOffset) ? this.i_maxOffset : this.offset;
                this.offset         = (this.distance < 0) ? -i_v.multiplier * this.offset : i_v.multiplier * this.offset;
                //if the touchtime is low enough, the offset is not null and the offset is above the offsetThreshold, (re)set the animation parameters to include momentum
                if ((this.touchTime < i_v.i_moveThreshold) && this.offset !== 0 && Math.abs(this.offset) > (i_v.i_offsetThreshold)) {
                    if (this.animProp) { this.animPar1[this.animProp] = i_v.i_elem.data(this.animProp) + this.distance + this.offset; }
                    this.animPar2   = { duration: i_v.i_duration, easing : 'hnlInertial', complete: function () {
                        //reset multiplier
                        i_v.multiplier = 1;
                    }};
                }
                break;
            }

            // run the animation on the element
            if ((this.i_scrollableY || this.i_scrollableX) && this.animProp) {
                i_v.i_elem.stop(true, false).animate(this.animPar1, this.animPar2);
            }
        }
    });

View/fork on Github: https://gist.github.com/c-kick/32798f731803f08dc469

Options

Options are set in the i_v object:

  • i_touchlistener element to monitor for touches, set to null to use document. Otherwise use quotes. Eg. '.myElement'. Note: if the finger leaves this listener while still touching, movement is stopped.
  • i_scrollElement element (class) to be scrolled on touch movement. Use quotes. Eg. '.myElement'.
  • i_duration (ms) duration of the inertial scrolling simulation. Devices with larger screens take longer durations (phone vs tablet is around 500ms vs 1500ms). This is a fixed value and does not influence speed and amount of momentum.
  • i_speedLimit (int) set maximum speed. Higher values will allow faster scroll (which comes down to a bigger offset for the duration of the momentum scroll) note: touch motion by the user determines actual speed, this is just a limit.
  • i_handleY (bool) should scroller handle vertical touch movement on i_scrollElement?
  • i_handleX (bool) should scroller handle horizontal touch movement on i_scrollElement?
  • i_moveThreshold (ms) determines if a swipe occurred: time between last updated movement @ touchmove and time @ touchend, if smaller than this value, trigger inertial scrolling
  • i_offsetThreshold (px) determines, together with i_moveThreshold, if a swipe occurred: if calculated offset is above this threshold
  • i_startThreshold (px) how many pixels finger needs to move before a direction (horizontal or vertical) is chosen. This will make the direction detection more accurate, but can introduce a delay when starting the swipe if set too high
  • i_acceleration (int) increase the multiplier by this value, each time the user swipes again when still scrolling. The multiplier is used to multiply the offset. Set to 0 to disable.
  • i_accelerationT (ms) time between successive swipes that determines if the multiplier is increased (if lower than this value)

Notes

  • The script adjusts itself to available screenspace (by using window.innerHeight for vertical scrolling – or innerWidth of the scrolling element for horizontal scrolling – to modify the acceleration and offset variables). This is done in accordance to native momentum scrolling; on a smartphone a scroll takes less time, and ‘travels’ for a shorter distance, than on a tablet for example. Note that this is not recalculated on an orientation change (when the screen decreases or increases in height). While I found this not necessary to include, you can write code that adjusts duration, max offset and max acceleration on an orientation change.
  • The script wil also ‘lock’ into horizontal or vertical direction; if the user starts swiping vertically, then for the duration of the movement, horizontal movement is disabled, and vice versa.
  • When the user swipes in quick succession, the offset will be multiplied (and thus scrolling is accelerated with each swipe), just like native scrolling. Parameters can be adjusted.
  • Between the touchmove and touchend, the animation (scrolling) of the element ends and starts again (if enough momentum is detected). This can potentially produce a very slight pause on slow devices, or on scroll elements with lots of content (images, elaborate css, etc). I am still looking for a way to resolve this. Suggestions are more than welcome.
  • The jQuery .animate method is not hardware accelerated when it comes to scrolling (as opposed to physically moving an element), so the animation might not look as smooth as you’d expect, especially for scrolling elements with lots of content (images, elaborate css, etc).
  • The script uses a global object for its settings, i_v, so make sure you modify that if it conflicts with your own code.

Things this script does not provide (yet)

  • Scroll back to top when the user taps iOS’ Safari’s status bar (there is no event for this, and it would surprise me if Apple introduced this).
  • The ‘Rubber banding’-effect when the user has reached either the bottom or top of the scrolling element. Not to honor Steve Jobs’ wish, but because it is patented by Apple (and too complex to simulate in this manner). Instead, scrolling will just come to an abrupt halt. Potentially, this could be compensated somewhat by calculating remaining scrolling space, and adjusting variables accordingly, but I’ll leave that up to you.
  • No native scrollbars (the thin black lines indicating position) will appear. This can be added though by coding some monitoring/updating to the scrollHeight property and the scrollTo event of the element and adding some styled divs to simulate the scrollbars.
    Though for most webkit browsers, you can add ::-webkit-scrollbar css properties to get the scrollbars to show (see the demo).

I hope this is of use to someone as it was to me; just plain momentum scrolling, no extra’s.

Creative Commons License
jQuery Inertial Scroller by HN Leussink is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.