Smooth Scrolling With JavaScript

Smooth Scrolling With JavaScript

A technical look at creating a library from scratch

A technical look at creating a library from scratch

Today, we will explore how smooth scrolling works on the web by building a smooth scrolling library from scratch that will have the following features:

  • zero dependencies
  • animations with cubic Bézier curves and easing presets — the most interesting part!
  • ability to scroll inside any element and not just window
  • specify the direction of scrolling
  • specify scroll amount in px (optional)
  • specify the duration over which the scroll will happen
  • callback to cancel the scrolling event at any point

The library will expose a function that will accept the different input parameters required like the element to scroll, the scroll amount, etc. as part of one object.

function smoothScroll(scrollParams = {}) {
  const elementToScroll = scrollParams.element;
  ...
}

Detecting the element type

For simplicity’s sake, let’s assume that we want to scroll inside the element from left to right. The first task is to find out the type of element — if it’s window or not. This is because window, compared to other HTML elements, has different DOM APIs to calculate width, height, and manipulating scroll positions.

function smoothScroll(scrollParams) {
  const elementToScroll = scrollParams.element;
  const isWindow = elementToScroll === window;
  ...
}

Based on the type of element, we use appropriate properties, as seen below.

function smoothScroll(scrollParams) {
      const elementToScroll = scrollParams.element;
      const isWindow = elementToScroll === window;
      const scrollDirectionProp = isWindow ? 'scrollX' : 'scrollLeft';
      const elementWidthProp = isWindow ? 'innerWidth' : 'clientWidth';
      const scrollLengthProp = 'scrollWidth';
      const initialScrollPosition = elementToScroll[scrollDirectionProp];
      ...
    }

Detect how much to scroll

The next step is to calculate how much to scroll if the scroll amount is not specified in the parameters. Otherwise, we calculate it based on the width of the element and its initial scroll position.

// returns the total scroll amount in pixels
    const getTotalScroll = ({
      isWindow,
      elementToScroll,
      elementWidthProp,
      initialScrollPosition,
      scrollLengthProp
    }) => {
      let totalScroll;

  if (isWindow) {
    const documentElement = document.documentElement;
    totalScroll = documentElement.offsetWidth
  } else {
    totalScroll = elementToScroll[scrollLengthProp] - elementToScroll[elementLengthProp];
  }
  return totalScroll - initialScrollPosition;
}


function smoothScroll(scrollParams) {
  const elementToScroll = scrollParams.element;
  const isWindow = elementToScroll === window;
  const scrollDirectionProp = isWindow ? 'scrollX' : 'scrollLeft';
  const elementWidthProp = isWindow ? 'innerWidth' : 'clientWidth';
  const scrollLengthProp = 'scrollWidth';
  const initialScrollPosition = elementToScroll[scrollDirectionProp];
  let totalScroll = getTotalScroll({
    isWindow,
    elementToScroll,
    elementWidthProp,
    initialScrollPosition,
    scrollLengthProp
  });
  ...
}

Triggering the smooth scroll

Now, we need to start scrolling the element at a pace based on the duration provided in the parameters. A continuously self-executing function is provided to requestAnimationFrame as a callback. requestAnimationFrame is a non-blocking way to call a function that performs an animation just before each repaint cycle of the browser.


On each tick, that is, each invocation of the callback function, the function will calculate the amount that needs to be scrolled. This will depend on two interdependent factors:

  • time elapsed since the start
  • animation parameters specified, which will dictate the pace of the scrolling

Animations and timing functions

In CSS, we have the provision of defining the animations of some properties like background-color and opacity through:


  • easing presets (ease-in , ease-out ,ease-in-out etc.)
  • cubic Bézier curve points

Under the hood, both of these methods use the concept of timing functions.

A timing function is a function of time and defines the variation of speed of an animation over a given duration, that is, its acceleration.

You can read in depth about timing functions here.

Unfortunately, there is no out-of-the-box way to define the animation of a scroll. So, we’ll have to wire that up ourselves!

In the context of our problem, the timing function will take the ratio of the time elapsed and the total duration of the animation as input. For example, if the duration specified was 2s, and 0.5s have elapsed, then the input to the timing function would be 0.5 / 2 = 0.25.

The return value lies between 0 and 1, which defines what fraction of the total scroll amount the element has to be scrolled to. For example, if the return value is 0.50 and the total scroll amount is 500px, that means the element has to be scrolled to 50% of 500, which is 250px.

Let’s look at the timing functions of some easing presets:

export default {
      // no easing, no acceleration
      linear(t) { return t },
      // accelerating from zero velocity
      easeInQuad(t) { return t * t },
      // decelerating to zero velocity
      easeOutQuad(t) { return t * (2 - t) },
      // acceleration until halfway, then deceleration
      easeInOutQuad(t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t },
      // accelerating from zero velocity 
      easeInCubic(t) { return t * t * t },
      // decelerating to zero velocity 
      easeOutCubic(t) { return (--t) * t * t + 1 },
      // acceleration until halfway, then deceleration 
      easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 },
      // accelerating from zero velocity 
      easeInQuart(t) { return t * t * t * t },
      // decelerating to zero velocity 
      easeOutQuart(t) { return 1 - (--t) * t * t * t },
      // acceleration until halfway, then deceleration
      easeInOutQuart(t) { return t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t },
      // accelerating from zero velocity
      easeInQuint(t) { return t * t * t * t * t },
      // decelerating to zero velocity
      easeOutQuint(t) { return 1 + (--t) * t * t * t * t },
      // acceleration until halfway, then deceleration 
      easeInOutQuint(t) { return t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t }
    }

To get more clarity, let’s take a preset, say easeOutQuad, and say we want to scroll a total amount of 200px over 2s. Here’s what the scroll position looks like at different points in time:

easeOutQuad dictates that an animation should start out fast and then become slow gradually,vas seen in the above table. The user can see the element being scrolled from 0 to 150px in the first second and then only 50px in the remaining second.

Cubic Bézier Curves

Easing presets are very specific and difficult to define. For example, easeInOutQuint, defined above, has a very complex formula but achieves only a very simple animation compared to cubic Bézier curves. For advanced customised animations, we require cubic Bézier curves that offer ease and ability to define complex acceleration patterns.


Image Source

Cubic Bézier curves for animations are defined in a 2D plane with the help of 4 points called control points — P0 (0, 0), P1, P2, P3(1, 1). The X axis specifies the time elapsed, and the Y axis tells us the progress percentage of the animation (in our case, what percent of the total scroll amount has been scrolled). You can (and should!) read more about Bézier curves here. Here’s a pen containing an interactive cubic Bézier curve implementation to get a feel of how they work.


The cubic Bézier function is a mathematical formula that takes percentage time elapsed, P1, and P2 control points as input and returns the percentage progress. In our case it will be translated to code as follows:

const B1 = (t) => {
      return Math.pow(t, 3);
    };

const B2 = (t) =&gt; {
  return 3 * t * t * (1 - t);
};


const B3 = (t) =&gt; {
  return 3 * t * Math.pow((1 - t), 2);
};


const B4 = (t) =&gt; {
  return Math.pow((1 - t), 3);
};


// the cubic bezier function
const getScrollTo = ({ percentTimeElapsed, x1, y1, x2, y2 }) =&gt; {
  // P0: (0, 0)
  // P1: (x1, y1)
  // P2: (x2, y2)
  // P3: (1, 1)
  // return value between 0 and 1 which is the percentage progress
  return 1 - (x1 * B1(percentTimeElapsed) + y1 * B2(percentTimeElapsed) + x2 * B3(percentTimeElapsed) + y2 * B4(percentTimeElapsed));
};


export default getScrollTo;

Now, let’s define the function that will return the animation percentage progress based on time elapsed.

import EASINGS from './easings';
    import getScrollTo from './bezier';

const getProgress = ({
  easingPreset, 
  cubicBezierPoints,
  duration,
  runTime
}) =&gt; {
  const percentTimeElapsed = runTime / duration;


  if (EASINGS.hasOwnProperty(easingPreset)) {
    return EASINGS[easingPreset](percentTimeElapsed);
  } else if (
    cubicBezierPoints
    &amp;&amp; !isNaN(cubicBezierPoints.x1) 
    &amp;&amp; !isNaN(cubicBezierPoints.y1) 
    &amp;&amp; !isNaN(cubicBezierPoints.x2) 
    &amp;&amp; !isNaN(cubicBezierPoints.y2)
    &amp;&amp; cubicBezierPoints.x1 &gt;= 0
    &amp;&amp; cubicBezierPoints.x2 &gt;= 0) {
    return getScrollTo({
      percentTimeElapsed,
      'x1': cubicBezierPoints.x1,
      'x2': cubicBezierPoints.x2,
      'y1': cubicBezierPoints.y1,
      'y2': cubicBezierPoints.y2
    });    
  } else {
    console.error('Please enter a valid easing value');
  }
  return false;
}

Tick Function

function smoothScroll(scrollParams) {
      const elementToScroll = scrollParams.element;
      const isWindow = elementToScroll === window;
      const scrollDirectionProp = isWindow ? 'scrollX' : 'scrollLeft';
      const elementWidthProp = isWindow ? 'innerWidth' : 'clientWidth';
      const scrollLengthProp = 'scrollWidth';
      const initialScrollPosition = elementToScroll[scrollDirectionProp];
      let totalScroll = getTotalScroll({
        isWindow,
        elementToScroll,
        elementWidthProp,
        initialScrollPosition,
        scrollLengthProp
      });

  let startTime;
  const { 
    easingPreset, 
    cubizBezierPoints, 
    duration,
    onAnimationCompleteCallback,
    onRefUpdateCallback
  } = scrollParams;

  // the tick function
  const scrollOnNextTick = (timestamp) =&gt; {
    // runTime is the number of milliseconds elapsed since the start of the animation
    const runTime = timestamp - startTime;

    // get the animation progress percentage ( &gt;= 0 &amp;&amp; &lt;= 1)
    const progress = getProgress({
      easingPreset, 
      cubicBezierPoints,
      runTime,
      duration
    });


    // amount to be scrolled for this tick
    const scrollAmt = progress * totalScroll;

    // calculate the final scroll position of the element
    const scrollToForThisTick = scrollAmt + initialScrollPosition;


    // the duration is not over
    if (runTime &lt; duration) {
      if (isWindow) {
        const xScrollTo = scrollToForThisTick;
        window.scrollTo(xScrollTo, yScrollTo);
      } else {
        scrollableDomEle[scrollDirectionProp] = scrollToForThisTick;        
      }

      // if a callback is supplied that should be called on each tick, call it. requestAnimationFrame(scrollOnNextTick) is      passed as an argument and can be used to cancel the animation anytime. If a callback is not supplied, call the tick function again recursively.
      if (onRefUpdateCallback) {
        onRefUpdateCallback(requestAnimationFrame(scrollOnNextTick));
      } else {
        requestAnimationFrame(scrollOnNextTick);
      }
    } else if (onAnimationCompleteCallback) {
      // fire the completion callback, if supplied, on completion of the animation
      onAnimationCompleteCallback();
    }
  }




  // calling the tick function for the first time
  requestAnimationFrame((timestamp) =&gt; {
    // timestamp is the amount of milliseconds elapsed since 01/01/1970
    startTime = timestamp;
    scrollOnNextTick(timestamp);
  });

}

Let’s understand what’s happening step by step.

  1. scrollOnNextTick is called for the first time, wrapped inside requestAnimationFramerequestAnimationFrame provides the number of milliseconds elapsed since 1970 as a default argument, which we store in startTime, and is also the argument to scrollOnNextTick on each tick.
  2. runTime is calculated on each tick, which tells us how much time has elapsed since the animation started.
  3. getProgress takes runTime as an argument and returns the animation progress percentage (a value between 0 and 1), which is multiplied with the total scroll amount that needs to be scrolled, giving us the scroll amount that needs to be scrolled in this tick.
  4. The scroll position is calculated and set based on the initial scroll position and the scroll amount for this tick.
  5. If onRefUpdateCallback is supplied, it will be called on each tick. requestAnimationFrame(scrollOnNextTick) is passed as an argument that can be used to cancel the scroll animation by passing it to cancelAnimationFrame as an argument.
  6. If runTime becomes greater than duration, it means the animation is complete. An optional callback onAnimationCompleteCallback is called if supplied.

Usage

An example of how the smoothScroll function can be used:

smoothScroll({
      scrollElement: window,
      scrollAmount: 300,
      cubicBezierPoints: {
        x1: 0.2,
        x2: 0.5,
        y1: 0.5,
        y2: 0.9
      },
      onRefUpdateCallback: (animationInstance) => {
        // cancelAnimationFrame(animationInstance);
      },
      onAnimationCompleteCallback: () => {
        console.log('animation complete!');
      },
      duration: 2000,
      direction: 'right'
    });

Wrapping it up!

The final step is to expose the smoothScroll function to be used by applications. For this, few things need to be done:


  • export smoothScroll as the default function:
export default smoothScroll
  • Compile ES6 to ES5 for use in browsers since all browsers can’t understand ES6 completely. For this, we can use any bundler (eg: Webpack).
  • (optional) An npm package can be created so that our library is npm installable.

You can check out the entire code in the following links:

GitHub: https://github.com/tarun-dugar/easy-scroll 

npm: https://www.npmjs.com/package/easy-scroll


Originally published by Tarun Dugar at medium.com

--------------------------------------------------------------------------------------------------------------------------------------

Thanks for reading :heart: If you liked this post, share it with all of your programming buddies! Follow me on Facebook | Twitter

Learn More

☞ Talking to Python from JavaScript (and Back Again!)

☞ Top 12 Javascript Tricks for Beginners

☞ Functional Programming in JavaScript

☞ JavaScript for Machine Learning using TensorFlow.js

☞ Learn JavaScript - JavaScript Course for Beginners

☞ The Complete JavaScript Course 2019: Build Real Projects!

☞ Become a JavaScript developer - Learn (React, Node,Angular)

☞ JavaScript: Understanding the Weird Parts

☞ Vue JS 2 - The Complete Guide (incl. Vue Router & Vuex)

☞ The Full JavaScript & ES6 Tutorial - (including ES7 & React)

☞ JavaScript - Step By Step Guide For Beginners

☞ The Web Developer Bootcamp

☞ MERN Stack Front To Back: Full Stack React, Redux & Node.js










javascript

Bootstrap 5 Complete Course with Examples

Bootstrap 5 Tutorial - Bootstrap 5 Crash Course for Beginners

Nest.JS Tutorial for Beginners

Hello Vue 3: A First Look at Vue 3 and the Composition API

Building a simple Applications with Vue 3

Deno Crash Course: Explore Deno and Create a full REST API with Deno

How to Build a Real-time Chat App with Deno and WebSockets

Convert HTML to Markdown Online

HTML entity encoder decoder Online

The essential JavaScript concepts that you should understand

The essential JavaScript concepts that you should understand - For successful developing and to pass a work interview

Data Types In JavaScript

JavaScript data types are kept easy. While JavaScript data types are mostly similar to other programming languages; some of its data types can be unique. Here, we’ll outline the data types of JavaScript.

JavaScript Memory Management System

The main goal of this article is help to readers to understand that how memory management system performs in JavaScript. I will use a shorthand such as GC which means Garbage Collection. When the browsers use Javascript, they need any memory location to store objects, functions, and all other things. Let’s deep in dive that how things going to work in GC.

Create a Line Through Effect with JavaScript

In this post we are going to create an amazing line through effect, with help of CSS and lots of JavaScript. So, head over to your terminal and create a folder LineThroughEffect. Create three files -index.html, main.js and styles.css inside it.

Grokking Call(), Apply() and Bind() Methods in JavaScript

In this article, we will have a look at the call(), apply() and bind() methods of JavaScript. Basically these 3 methods are used to control the invocation of the function.