How to Implement Smooth Scrolling in Vanilla JavaScript

Giulio Mainardi
Share

This article was peer reviewed by Adrian Sandu, Chris Perry, Jérémy Heleine and Mallory van Achterberg. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!

Smooth scrolling is a user interface pattern that progressively enhances the default in-page navigation experience, animating the change of position within the scroll box (the viewport, or a scrollable element) from the location of the activated link to the location of the destination element indicated in the hash fragment of the link URL.

This is nothing new, being a pattern known from many years now, check for instance this SitePoint article that dates back to 2003! As an aside, this article has a historical value as it shows how client-side JavaScript programming, and the DOM in particular, has changed and evolved over the years, allowing the development of less cumbersome vanilla JavaScript solutions.

There are many implementations of this pattern within the jQuery ecosystem, either using jQuery directly or implemented with a plugin, but in this article we are interested in a pure JavaScript solution. Specifically, we are going to explore and leverage the Jump.js library.

After a presentation of the library, with an overview of its features and characteristics, we will apply some changes to the original code to adapt it to our needs. In doing this, we will refresh some core JavaScript language skills such as functions and closures. We will then create a HTML page to test the smooth scrolling behavior that it will be then implemented as a custom script. Support, when available, for native smooth scrolling with CSS will then be added and finally we will conclude with some observations concerning the browser navigation history.

Here is a is the final demo that we’ll be creating:

See the Pen Smooth Scrolling by SitePoint (@SitePoint) on CodePen.

The full source code is available on GitHub.

Jump.js

Jump.js is written in vanilla ES6 JavaScript, without any external dependencies. It is a small utility, being only about 42 SLOC, but the size of the provided minified bundle is around 2.67 KB because it has to be transpiled. A Demo is available on the GitHub project page.

As suggested by the library name, it provides only the jump: the animated change of the scrollbar position from its current value to the destination, specified by providing either a DOM element, a CSS selector, or a distance in the form of a positive or negative number value. This means that in the implementation of the smooth scrolling pattern, we must perform the link hijacking ourselves. More on this in the following sections.

Note that currently only the scrolling of the viewport is supported and only vertically.

We can configure a jump with some options such as the duration (this parameter is mandatory), the easing function, and a callback to be fired at the end of the animation. We will see them in action later in the demo. See the documentation for full details.

Jump.js runs without problems on ‘modern’ browsers, including Internet Explorer version 10 or higher. Again, refer to the documentation for the full list of supported browsers. With a suitable polyfill for requestAnimationFrame it should run even on older browsers.

A Quick Peek Behind the Screen

Internally the Jump.js source uses the requestAnimationFrame method of the window object to schedule the update of the position of the viewport vertical position at each frame of the scrolling animation. This update is achieved passing the next position value computed with the easing function to the window.scrollTo method. See the source for full details.

A Bit of Customization

Before diving into a demo to show the usage of Jump.js, we are going to make some slight changes to the original code, that will however leave its inner workings unmodified.

The source code is written in ES6 and needs to be used with a JavaScript build tool for transpiling and bundling modules. This could be overkill for some projects, so we are going to apply some refactoring to convert the code to ES5, ready to be used everywhere.

First things first, let’s remove the ES6 syntax and features. The script defines an ES6 class:

import easeInOutQuad from './easing'

export default class Jump {
  jump(target, options = {}) {
    this.start = window.pageYOffset

    this.options = {
      duration: options.duration,
      offset: options.offset || 0,
      callback: options.callback,
      easing: options.easing || easeInOutQuad
    }

    this.distance = typeof target === 'string'
      ? this.options.offset + document.querySelector(target).getBoundingClientRect().top
      : target

    this.duration = typeof this.options.duration === 'function'
      ? this.options.duration(this.distance)
      : this.options.duration

    requestAnimationFrame(time => this._loop(time))
  }

  _loop(time) {
    if(!this.timeStart) {
      this.timeStart = time
    }

    this.timeElapsed = time - this.timeStart
    this.next = this.options.easing(this.timeElapsed, this.start, this.distance, this.duration)

    window.scrollTo(0, this.next)

    this.timeElapsed < this.duration
      ? requestAnimationFrame(time => this._loop(time))
      : this._end()
  }

  _end() {
    window.scrollTo(0, this.start + this.distance)

    typeof this.options.callback === 'function' && this.options.callback()
    this.timeStart = false
  }
}

We could convert this to a ES5 ‘class’ with a constructor function and a bunch of prototype methods, but observe that we never need multiple instances of this class, so a singleton implemented with a plain object literal will do the trick:

var jump = (function() {

    var o = {

        jump: function(target, options) {
            this.start = window.pageYOffset

            this.options = {
              duration: options.duration,
              offset: options.offset || 0,
              callback: options.callback,
              easing: options.easing || easeInOutQuad
            }

            this.distance = typeof target === 'string'
              ? this.options.offset + document.querySelector(target).getBoundingClientRect().top
              : target

            this.duration = typeof this.options.duration === 'function'
              ? this.options.duration(this.distance)
              : this.options.duration

            requestAnimationFrame(_loop)
        },

        _loop: function(time) {
            if(!this.timeStart) {
              this.timeStart = time
            }

            this.timeElapsed = time - this.timeStart
            this.next = this.options.easing(this.timeElapsed, this.start, this.distance, this.duration)

            window.scrollTo(0, this.next)

            this.timeElapsed < this.duration
              ? requestAnimationFrame(_loop)
              : this._end()
        },

        _end: function() {
            window.scrollTo(0, this.start + this.distance)

            typeof this.options.callback === 'function' && this.options.callback()
            this.timeStart = false
        }

    };

    var _loop = o._loop.bind(o);

    // Robert Penner's easeInOutQuad - http://robertpenner.com/easing/
    function easeInOutQuad(t, b, c, d)  {
        t /= d / 2
        if(t < 1) return c / 2 * t * t + b
        t--
        return -c / 2 * (t * (t - 2) - 1) + b
    }

    return o;

})();

Apart from removing the class, we needed to make a couple of other changes. The callback for requestAnimationFrame, used to update the scrollbar position at each frame, which in the original code is invoked through an ES6 arrow function, is pre-bound to the jump singleton at init time. Then we bundle the default easing function in the same source file. Finally, we have wrapped the code in an IIFE(Immediately-invoked Function Expressions) to avoid namespace pollution.

Now we can apply another refactoring step, noting that with the help of nested functions and closures we can just use a function instead of an object:

function jump(target, options) {
    var start = window.pageYOffset;

    var opt = {
      duration: options.duration,
      offset: options.offset || 0,
      callback: options.callback,
      easing: options.easing || easeInOutQuad
    };

    var distance = typeof target === 'string' ? 
        opt.offset + document.querySelector(target).getBoundingClientRect().top : 
        target
    ;

    var duration = typeof opt.duration === 'function'
          ? opt.duration(distance)
          : opt.duration
    ;

    var 
        timeStart = null,
        timeElapsed
    ;

    requestAnimationFrame(loop);

    function loop(time) {
        if (timeStart === null)
            timeStart = time;

        timeElapsed = time - timeStart;

        window.scrollTo(0, opt.easing(timeElapsed, start, distance, duration));

        if (timeElapsed < duration)
            requestAnimationFrame(loop)
        else
            end();
    }

    function end() {
        window.scrollTo(0, start + distance);

        typeof opt.callback === 'function' && opt.callback();
        timeStart = null;
    }

    // ...

}

The singleton now becomes the jump function that will be called to animate the scroll, and the loop and end callbacks become nested functions, while the object’s properties now become local variables (closures). We don’t need the IIFE anymore, because now all the code is safely wrapped in the one function.

As a last refactoring step, to avoid repeating the timeStart reset check at each invocation of the loop callback, the first time requestAnimationFrame() is called we pass it an anonymous function that resets the timerStart variable before calling the loop function:

requestAnimationFrame(function(time) { timeStart = time; loop(time); });

function loop(time) {
    timeElapsed = time - timeStart;

    window.scrollTo(0, opt.easing(timeElapsed, start, distance, duration));

    if (timeElapsed < duration)
        requestAnimationFrame(loop)
    else
        end();
}

Once again note that in the course of refactoring, the core scrolling animation code didn’t change.

The Test Page

Now that we have customized the script to suit our needs, we are ready to assemble a testing demo. In this section we’ll write the page that will be enhanced with smooth scrolling, using the script presented in the next section.

The page consists of a table of contents (TOC) with in-page links to following sections in the document, and with additional links back to the TOC. We will mix-in some external links pointing to other pages too. Here’s the basic structure of this page:

<body>
    <h1>Title</h1>
    <nav id="toc">
        <ul>
            <li><a href="#sect-1">Section 1</a></li>
            <li><a href="#sect-2">Section 2</a></li>
            ...
        </ul>
    </nav>
    <section id="sect-1">
        <h2>Section 1</h2>
        <p>Pellentesque habitant morbi tristique senectus et netus et <a href="http://www.example.net/">a link to another page</a> ac turpis egestas. <a href="http://www.example.net/index.html#foo">A link to another page, with an anchor</a> quam, feugiat vitae, ...</p>
        <a href="#toc">Back to TOC</a>
    </section>
    <section id="sect-2">
        <h2>Section 2</h2>
        ...
    </section>
    ...
    <script src="jump.js"></script>
    <script src="script.js"></script>
</body>

In the head, we’ll include a few CSS rule to setup a basic, minimal layout, while at the end of the body tag we include two JavaScript files: the former is our refactored version of Jump.js and the latter is the script that we will now discuss.

The Master Script

This is the script that will enhance the scrolling experience of the test page with the animated jumps provided by our custom version of the Jump.js library. Of course, this code will also be written in ES5 JavaScript.

Let’s briefly outline the tasks the it should accomplish: It must hijack clicks on in-page links, disabling the browser’s default behavior (the abrupt jump to the target element, indicated in the hash fragment of the href attribute of the clicked link), and replace it with a call to our jump() function.

So, first thing is to monitor the clicks on the in-page links. We can do this in two ways, with event delegation or attaching the handler to each relevant link.

Event Delegation

In the first approach we add our click listener to only one element, document.body. In this way, every click event on any element of the page will bubble up the DOM tree along the branch of its ancestors until it reaches document.body:

document.body.addEventListener('click', onClick, false);

Of course, now in the registered event listener(onClick) we have to inspect the target of the incoming click event object to check that it is related to an in-page link element. This can be done in several ways, so we abstract it to a helper function, isInPageLink(). We’ll have a look at the mechanics of this function in a moment.

If the incoming click is on an in-page link, we stop the event bubbling and prevent the associated default action. Finally we call the jump function, providing it with the hash selector for the target element and the parameters to configure the desired animation.

Here’s the event handler:

function onClick(e) {
    if (!isInPageLink(e.target))
        return;

    e.stopPropagation();
    e.preventDefault();

    jump(e.target.hash, {
        duration: duration
    });
}

Individual Handlers

With the second approach to monitoring the link clicks, a slight modified version of the event handler presented above is attached to each in-page link element, so there is no event bubbling:

[].slice.call(document.querySelectorAll('a'))
    .filter(isInPageLink)
    .forEach(function(a) { a.addEventListener('click', onClick, false); });

We query for all <a> elements, and convert the returned DOM NodeList to a JavaScript array with the [].slice() hack (If the target browsers support it, a better alternative would be to use the ES6 Array.from() method). Then we can use the array methods to filter the in-page links, re-using the same helper function defined above, and finally attach the listener to the remaining link elements.

The event handler is almost the same as before but of course we don’t need to check the click target:

function onClick(e) {
    e.stopPropagation();
    e.preventDefault();

    jump(hash, {
        duration: duration
    });
}

Which approach is best depends on the use context. For example, if new links elements may be dynamically added after the initial page load, then we must use event delegation.

Now we turn to the implementation of isInPageLink(), the helper function we used in the previous event handlers to abstract the test for the in-page links. As we have seen, this function takes a DOM node as an argument and returns a boolean value to indicate if the node represents an in-page link element. It is not sufficient to check that the passed node is an A tag and that it has an hash fragment set, because the link could be to another page and in this case the default browser action must not disabled. So we check if the value stored in the attribute href ‘minus’ the hash fragment is equal to the page URL:

function isInPageLink(n) {
    return n.tagName.toLowerCase() === 'a' 
        && n.hash.length > 0
        && stripHash(n.href) === pageUrl
    ;
}

stripHash() is another helper function that we also use to set the value of the variable pageUrl when the script is initialized:

var pageUrl = location.hash
        ? stripHash(location.href)
        : location.href
    ;

function stripHash(url) {
    return url.slice(0, url.lastIndexOf('#'));
}

This string-based solution with the trimming of the hash fragment works even with URLs with query strings because the hash part comes after them in the general structure of an URL.

As I said before, this is only one possible way to implement this test. For example, the article cited at the beginning of this tutorial uses a different solution, making a component-wise comparison of the link href with the location object.

It should be noted that we have used this function in both approaches to event subscription, but in the second one we are using it as a filter for elements that we already know are <a> tags so the first check on the tagName attribute is redundant. This is left as an exercise for the reader.

Accessibility Considerations

As it stands, our code is vulnerable to a known bug (actually a pair of unrelated bugs hitting Blink/WebKit/KHTML and one hitting IE) which affects keyboard users. When tabbing through the TOC links, activating one will smoothly scroll down to the selected section, but the focus will remain on the link. This means that on the next tab key press the user will be sent back up to the TOC, rather than to the first link within their chosen section.

To fix this, we’re going to add another function to our master script:

// Adapted from:
// https://www.nczonline.net/blog/2013/01/15/fixing-skip-to-content-links/
function setFocus(hash) {
    var element = document.getElementById(hash.substring(1));

    if (element) {
        if (!/^(?:a|select|input|button|textarea)$/i.test(element.tagName)) {
            element.tabIndex = -1;
        }

        element.focus();
    }
}

which will be run in a callback that we’ll pass to the jump function, passing the hash value of the element we’re scrolling to:

jump(e.target.hash, {
    duration: duration,
    callback: function() {
        setFocus(e.target.hash);
    }
});

What this function does is fetch the DOM element that the hash value corresponds to, and test to see if it’s already an element that can receive the focus (e.g. an anchor or a button element). If the element cannot receive the focus by default (like our <section> containers), it then sets its tabIndex attribute to -1 (allowing it to receive the focus programmatically, but not via keyboard). The focus is then set to that element, meaning that the user’s next tab press will move the focus to the next available link.

You can view the complete source of the master script, with all the previously discussed changes, here.

Supporting Native Smooth Scrolling with CSS

The CSS Object Model View module specification introduces a new property to natively implement smooth scrolling: scroll-behavior.

It can take two values, auto for the default instant scrolling and smooth for the animated scrolling.
The specification doesn’t provide any way to configure the animation of the scroll, such as its duration and the timing function (easing).

Can I Use css-scroll-behavior? Data on support for the css-scroll-behavior feature across the major browsers from caniuse.com.

Unfortunately, at the time of writing the support is very limited. In Chrome this feature is under development and a partial implementation is available by enabling it in the chrome://flags screen. The CSS property is is not implemented yet so the smooth scrolling on link clicks doesn’t work.

Anyway, with a tiny change to our master script, we can detect if this feature is available in the user agent and avoid running the rest of our code. To use smooth scrolling on the viewport we apply the CSS property to the root element, HTML (but in our test page we could even apply it to the body element):

html {
    scroll-behavior: smooth;
}

Then we add a simple feature-detection test at the beginning of the script:

function initSmoothScrolling() {
    if (isCssSmoothSCrollSupported()) {
        document.getElementById('css-support-msg').className = 'supported';
        return;
    }
    //...
}

function isCssSmoothSCrollSupported() {
    return 'scrollBehavior' in document.documentElement.style;
}

Hence, if the browser supports the native scrolling the script does nothing and quits, otherwise it will continue the execution as before and the unsupported CSS property will be ignored by the browser.

Conclusion

A further advantage of the CSS solution just discussed, beyond implementation simplicity and performance, is that the browser history behavior is consistent with what you experience when using the browser’s default scrolling. Every in-page jump is pushed on the browser history stack and we can go back-and-forth through these with the respective buttons (but without smooth scrolling, at least in Firefox).

In the code we wrote (that we could now consider as a fallback for when the CSS support is not available) we did not take into consideration the behavior of the script with respect to the browser history. Depending on the context and use-case, this is something that may or may not be of interest, but if we are taking the view that the script should enhance the default scrolling experience we should expect consistent behavior, as it happens with CSS.