It is no secret that I think we web developers should all stop using jQuery. Even when IE11 compatibility is still needed, most of jQuery’s functions can be handled by native Javascript without the need for an extra resource download, and the CPU weight of processing all that code. In real world tests, even simple jQuery functions can be as much as 25 times slower than their native Javascript counterparts. It may not seem like a big deal for one-off instances, but combined, the result is a poor user experience and loss of points in Page Speed Insights, affecting the website’s search engine result placement.

But what are we to do with all these very useful snippets of jQuery that do effectively improve the user’s experience? Replace them with native code, of course. And here is one such example.

$(document).on('click', 'a[href^="#"]', function(event) {
    event.preventDefault();

    $('html, body').animate({
        scrollTop: $($.attr(this, 'href')).offset().top
    }, 800);
});

This snippet should allow links to anchors on the same page to smoothly scroll through the page instead of jumping immediately to the appropriate location in the content. It’s an incredibly common experience that the animation makes more pleasing. For example, this link will take you to the end of the page, where the final replacement code is at.

For a long time though, modern browsers have included a set of JavaScript functions that do this animation natively. The browser function is also smart enough to recognize user preferences and do a hard jump when the user has animations turned off. It’s a far more accessible option to replace your jQuery with native functionality. But how?

You’ll notice the above code includes several functions of jQuery, including click-event handling. We’ll need to replace that along with the animation. Start with an event handler on the document:

document.addEventListener( 'click', function( event ) {

} );

Simple as pie, right? Actually simpler. Pie can be difficult to make. But this isn’t the whole story, as this click event handler will get fired everytime someone clicks ANYWHERE on the page. We need to only specificly target anchor elements that have an href starting with the hash character #, and we do so by getting some information from the event.

document.addEventListener( 'click', function( event ) {
    let clickTarget = event.target;
    if ( clickTarget.tagName === 'A' ) {
        // do something
    }
} );

The event variable provides quite a bit of information about what happened with the user’s click, including what the user clicked on. That DOM element is on the target property of the event object. From event.target, we can determine what kind of element was clicked on, and fallback to default behavior if it was not an anchor tag.

Only, it’s not quite that straightforward, unfortunately. If all your anchors have just text in them, it’ll work fine, but what if your <a> tag contains an image or some other child element? event.target might then be the child element instead, and the above code won’t work, so we need one more check to walk up the DOM tree and see if we’re inside an appropriate anchor.

document.addEventListener( 'click', function( event ) {
    let clickTarget = event.target.closest('a');
    if ( ! clickTarget ) {
        return;
    }
    // do something
} );

The closest method returns either itself or its nearest parent that matches the given selector. So if we call .closest('a') on event.target, it will either (a) return itself, (b) return the appropriate parent element, or (c) return null. If it returns null, we exit and fall back to the existing behavior.

But that’s not quite all, for this functionality, we only want to target anchor elements that link to other places in the same page. As is, our functionality would be fired for every single <a> link.

document.addEventListener( 'click', function( event ) {
    let clickTarget = event.target.closest('a');
    if ( ! clickTarget ) {
        return;
    }
    let linkUrl = new URL( clickTarget.href );
    if ( linkUrl.hostname !== document.location.hostname 
      || linkUrl.pathname !== document.location.pathname
      || linkUrl.hash === '' ) {
        return;
      }
    // do something
} );

With this new code we do several things that actually improve upon the jQuery example we’re replacing. After converting the .href property of the anchor to a full URL object, we can test it against certain properties of the page the user is currently on. Fist we see if the domains (hostname) match, then we see if the page URL paths (pathname) match, and then finally we check to see if the URL has a hash in it at all. If any of these tests fail, we fall back to existing behavior.

Now we need to find an element on the page matching the hash from the anchor tag. We can do that with another native function, getElementById.

document.addEventListener( 'click', function( event ) {
    let clickTarget = event.target.closest('a');
    if ( ! clickTarget ) {
        return;
    }

    let linkUrl = new URL( clickTarget.href );
    if ( linkUrl.hostname !== document.location.hostname 
      || linkUrl.pathname !== document.location.pathname
      || linkUrl.hash === '' ) {
        return;
    }

    let anchorElement = document.getElementById( linkUrl.hash.substring( 1 ) );
    if ( ! anchorElement ) {
        return;
    }
} );

Here we use the native string method, substring, to trim off the ‘#’ character from the beginning the of anchor hash, and then try to find an element matching that ID with the getElementById method.

You may ask, why trim the hash when you could put the entire string into querySelector? Well, in theory, the old-as-dirt getElementById method should be faster for the browser to perform, as it will only have to traverse an indexed list of all the elements on the page that have an ID, whereas querySelector could possibly traverse the entire DOM tree. But it’s also very possible that modern browsers have optimized the querySelector method to work effectively the same. So if you think querySelector looks cleaner, use it!

If getElementById (or querySelector) don’t find an element that matches, they will return null, we exit, and return to fallback browser behavior. This might be something you want to change, as the default behavior can be annoying.

Next, let’s finally do some scrolling!

document.addEventListener( 'click', function( event ) {
    let clickTarget = event.target.closest('a');
    if ( ! clickTarget ) {
        return;
    }

    let linkUrl = new URL( clickTarget.href );
    if ( linkUrl.hostname !== document.location.hostname 
      || linkUrl.pathname !== document.location.pathname
      || linkUrl.hash === '' ) {
        return;
    }

    let anchorElement = document.getElementById( linkUrl.hash.substring( 1 ) );
    if ( ! anchorElement ) {
        return;
    }

    event.preventDefault();

    let offset = 100;
    let anchorBox = anchorElement.getBoundingClientRect();
    window.scrollTo({
        top: anchorBox.y + window.pageYOffset - offset,
        left: 0,
        behavior: 'smooth'
    });

} );

And that’s it! Now we have completely functioning smooth scrolling code to replace jQuery. So what’s going on here?

First, we finally tell the browser to stop parsing the rest of the event listeners. At this point in the code, we know we have everything we need to do our work, so event.preventDefault() tells the browser not to fallback to any other behavior.

Then we set an offset value. You can change this to whatever you like, and will give you the ability to scroll to just a little bit before an element on the page, so that it’s clear you’ve landed in the right place. Often times navigation gets in the way of a header element or whatever you wanted the user to scroll to, and so if we give the browser a little space, the experience is much more pleasant.

Then with getBoundingClientRect we determine the coordinates of the element we would like to scroll to. This function returns the relative position of the object compared to wherever the user is already scrolled to on the page, so we will need to further offset it with the current window’s scroll position.

Lastly, we use the scrollTo method to do the work of scrolling. Natively, if you pass the behavior property to it, the browser will typically animate the scroll without any work from us, so that’s what we do here.

But, why not use scrollIntoView and avoid all the math? Using this method, if we wanted to provide an offset to the scroll anchor, we’d either have to add padding to the top of every element we scroll to, or use the CSS property scroll-margin-top. But this would also require some editing of our CSS, and the pure JS solution is self-contained.

There you have it! Smooth scrolling without an enormous code library attached to your website. It may look like a lot more code, but much of that is because we spend a lot of code double checking for failures and respecting the behavior of the browser or other code that could be installed on the site.

If you enjoyed this or found an error, please drop me a line using the contact methods in the site footer. And let me know if there are other common jQuery uses you’d like to see replaced natively!