Categories
Interaction Design
Software Engineering

Published
Apr 15, 2015

Read
9 min

Tweet
Share
Comment

Material Design Overscroll in Vanilla JavaScript

Article Purpose

The purpose of this article is to demonstrate an approach for creating the Material Design overscroll animation using HTML, CSS, and vanilla JavaScript. If you are unfamiliar with this user feedback animation, you can see an example GIF or check out the interactive codepen sketch (mobile and desktop supported) below.

Interactive Codepen Sketch

Example
Material Design Overscroll in Vanilla JavaScript
The iOS patented (don’t get me started) overscroll animation is a superior approach in my eyes as it more clearly communicates that a boundary is reached by showing the space outside the content boundary. Regardless, both the Android and iOS approaches leverage the same concepts discussed in this article.

Conceptual Breakdown

The overscroll concept is comprised of seven elements which combine to reaffirm to the user that he/she has reached a content boundary. Overscroll also tells the user that they are not experiencing or causing an error within the interface. In addition, overscroll feedback is used to influence UX by providing character to the user interface. The seven elements of overscroll are:

  1. Mask (aka Clipping Area or Window)
  2. Content
  3. Content Container (aka Wrapper)
  4. Scrolling (aka Panning)
  5. Scrolling Boundaries
  6. Tracked User Input
  7. Feedback Animation

In short, the Mask provides the viewing area for Content. The Content Container parents the content and is used in conjunction with Scrolling and Tracked User Input to update the content container’s position. The Scrolling Boundaries are reached when the content container’s position meets or exceeds the boundary values. When this happens, a Feedback Animation is executed to communicate to the user that they have reached an overscroll condition.

Scrolling boundaries are typically constrained to horizontal or vertical space (x-axis and y-axis), but depth space (z-axis) or any combination of the three could be leveraged depending on the requirements or creativity of the user interface. Panning a large map for instance often leverages both horizontal and vertical space.

Technical Breakdown

Having described the core elements of overscroll, I will now highlight some of the technical aspects to show how I achieved the Material Design overscroll in vanilla JavaScript. First, I will break down the HTML and CSS which are used for the Mask, Content, and Content Container elements. As a side effect of creating the mask, the Scrolling Boundaries get set automatically. The Scrolling element is handled by the browser but I tap into it using JavaScript. In addition, the Tracked User Input and Feedback Animation elements are also handled by JavaScript. Let’s dig in.

HTML and CSS

You can view the HTML and CSS code below. Take note of each id name as it will match its corresponding overscroll element mentioned above.


<div id="mask">
<svg id="feedback-animation-canvas" width="400" height="680" opacity=".6">
  <circle id="feedback-animation" class="hidden" fill="black" opacity="0"
             cx="100" cy="-975" r="1000" />
</svg>
<div id="content-container">
  <div class="color-green">Greenish</div>  
  <div class="color-blue">Blueish</div> 
  <div class="color-pink">Pinkish</div> 
  <!-- more content here... you get the idea -->
</div>
</div>


#mask{
  height: 100%;
  width: 100%;
  overflow: hidden;
  background-color: #999;
}

svg { overflow: hidden; } /*IE 9-11 requirement*/

#feedback-animation-canvas {
  position: absolute;
  pointer-events:none;
}

#content-container {
  width: 100%;
  height: 100%;
  overflow: auto;
}

The HTML/CSS code is straight forward as it maps to what I’ve talked about thus far. The piece that is somewhat unique is the #feedback-animation-canvas <svg> element and its nested #feedback-animation <circle> element. The #feedback-animation-canvas is acting as the renderable area for the #feedback-animation. As a result the renderable area “clips” or “masks” the <circle> so only the portion that I want to show up on screen does so (note the svg { overflow: hidden; } requirement for proper clipped rendering in IE). This is a simple approach, but you could replace the <circle> with a custom drawn shape that leverages a Bézier curve to create the same effect.

JavaScript

Using jQuery could have simplified portions within the code below, but I intentionally wanted to use vanilla JavaScript to show it can easily be done without jQuery. Feel free however to make a jQuery version or even a plugin based off my code below if you’re into that. The below JS is broken down into more digestible bites, but you can view the entire interactive codepen sketch if you prefer everything at once.

Below is an excerpt of the cache that is used to prevent multiple look ups, record initial values set by CSS for use in calculations, and to enable proper clears (clearTimeout and clearInterval) during mid-animation.


    //touch support check
var SUPPORTS_TOUCH = "ontouchstart" in window,

    //view lookups
    shell = document.getElementById('frame'),
    bounds = document.getElementById('feedback-animation-canvas'),
    dot = document.getElementById('feedback-animation'),
    scroller = document.getElementById('content-container'),

    //feedback animation helper settings and read initial css settings
    dotSettings = { cxOrig: dot.getAttribute('cx'), 
                    cyOrig: dot.getAttribute('cy'),
                    cyOrigMax: 0,
                    cyOffset: dot.getAttribute('r') - Math.abs(dot.getAttribute('cy')),
                    r: dot.getAttribute('r'),
                    isAtMinBounds: true,
                    SCALER: -15,
                    X_INCREMENTER: 4,
                    Y_INCREMENTER: 3,
                    ALPHA_INCREMENTER: .08,
                    ALPHA_MULTIPLIER: .6,
                    CLEAR_TIME: 500,
                    CLEAN_BOUNDS_INTERVAL: 25 },

    //initial user input y pos                    
    inputY = 0,

    //clear animation helpers
    cleanBoundsTimeout,
    cleanBoundsInterval;

After the cache is set up, I initialize the main listeners for user interaction based on the environment (touch vs non-touch). These are used to help determine when the boundary values get hit. This is done by tracking user input and measuring the distances between boundaries and said input. This listener setup relates to a common pattern (not just in JS) for tracking input movement in an effecient manner. The pattern sequence is:

  1. Listen for down input
  2. When down input occurs, start to listen for move and up input
  3. When move input occurs, record data associated with the movement
  4. Analyze that data to determine what needs updated (visually or programmatically)
  5. When up input occurs, stop listening for move and up input
  6. Clean up any analyzed move data and update the view

//setup (touch/non-touch)
scroller.addEventListener(SUPPORTS_TOUCH ? "touchstart" : "mousedown", onDown);

Below are the onDown(), onMove(), and onUp() listeners that handle user input mentioned in the sequence above.


function onDown(e) {
  
    //top vs bottom bounds
    if(scroller.scrollTop === 0) {

        //flag for top boundary
        dotSettings.isAtMinBounds = true;

        //position at top
        updateDot(dotSettings.cxOrig, dotSettings.cyOrig, 0);

    } else if (scroller.scrollTop + scroller.clientHeight === scroller.scrollHeight) {
        
        //flag for bottom boundary
        dotSettings.isAtMinBounds = false;

        //update helper value for dot
        dotSettings.cyOrigMax = (dotSettings.cyOrig * -1) + scroller.clientHeight;

        //position at bottom
        updateDot(dotSettings.cxOrig, dotSettings.cyOrigMax, 0);

    } else {

        //allow scroll to edge to still trigger overscroll animation
        scroller.addEventListener("scroll", onScrolling);
        return;
    }

    //environment based input
    inputY = SUPPORTS_TOUCH ? e.touches[0].clientY : e.clientY;
    window.addEventListener(SUPPORTS_TOUCH ? "touchend" : "mouseup", onUp);
    scroller.addEventListener(SUPPORTS_TOUCH ? "touchmove" : "mousemove", onMove);

    //class updates
    dot.setAttribute('class', '');
    shell.setAttribute('class', 'cursor-dot-down');

    //clear cleanup (user could have input while animating from previous input)
    if(cleanBoundsTimeout) { 
        clearTimeout(cleanBoundsTimeout);
        clearInterval(cleanBoundsInterval);
    }
}


function onMove(e) {

  //temp cache
  var newY, newA,
      clientX = SUPPORTS_TOUCH ? e.touches[0].clientX : e.clientX,
      clientY = SUPPORTS_TOUCH ? e.touches[0].clientY : e.clientY;
  
  //bounds top vs bottom
  if(dotSettings.isAtMinBounds) {

    //mimic onUp as user is no longer dragging to see more content
    if(clientY < inputY) { onUp(); return; }

    //update y pos and alpha values for dot
    newY = dotSettings.cyOrig - (clientY - scroller.offsetTop)/dotSettings.SCALER;
    newA = (clientY - scroller.offsetTop) / shell.clientHeight;
  } else {

    //mimic onUp as user is no longer dragging to see more content
    if(clientY > inputY) { onUp(); return; }

    //update y pos and alpha values for dot
    newY = dotSettings.cyOrigMax - dotSettings.cyOffset/2 - (clientY - scroller.offsetTop)/dotSettings.SCALER;
    newA = 1 - (clientY - scroller.offsetTop) / shell.clientHeight;
  }
  
  //update
  inputY = clientY;
  updateDot((clientX - scroller.offsetLeft), newY, newA);
}


function onUp(e) {

    //environment based input
    window.removeEventListener(SUPPORTS_TOUCH ? "touchend" : "mouseup", onUp);
    scroller.removeEventListener(SUPPORTS_TOUCH ? "touchmove" : "mousemove", onMove);

    //class updates
    shell.setAttribute('class', 'cursor-dot');

    //clear cleanup (user could have input while animating from previous input)
    if(cleanBoundsTimeout) { 
        clearTimeout(cleanBoundsTimeout);
        clearInterval(cleanBoundsInterval);
    }

    //overscroll
    overscrollAnimation();
}

Now that all the input listeners are taken care of, we need to do something based on the final onUp() which triggers the feedback animation. Below is the code that actually mirrors the Material Design animation. It’s worth noting at this point, that you could get creative and handle the overscroll condition in another way or do a spinoff like Daniel Zellar did.


function overscrollAnimation() {

    //update reference for early clearing if required
    cleanBoundsTimeout = setTimeout(function(){

        //class updates
        dot.setAttribute('class', 'hidden');

        //clear below animation update
        clearInterval(cleanBoundsInterval);

    }, dotSettings.CLEAR_TIME);

    //animate
    cleanBoundsInterval = setInterval(function(){

        //temp cache
        var currX = dot.getAttribute('cx'),
            currY = dot.getAttribute('cy'),
            newX, newY, newA;    

        //x pos update
        if(currX < dotSettings.cxOrig) { 
            newX = parseFloat(currX) + dotSettings.X_INCREMENTER;
        } else if(currX > dotSettings.cxOrig) { 
            newX = parseFloat(currX) - dotSettings.X_INCREMENTER; 
        }

        //y pos update
        if(currY < dotSettings.cyOrig) { 
            newY = parseFloat(currY) - dotSettings.Y_INCREMENTER; 
        } else if(currY > dotSettings.cyOrig) {
            newY = parseFloat(currY) + dotSettings.Y_INCREMENTER; 
        }

        //alpha update
        newA = parseFloat(dot.getAttribute('opacity')) - dotSettings.ALPHA_INCREMENTER;

        //update 2d pos and alpha
        updateDot(newX, newY, newA);

    }, dotSettings.CLEAN_BOUNDS_INTERVAL);
}

Conclusion

Once you understand the core seven components of overscroll (and see the code associated), it's easy to create your own feedback animation solution. The Material Design approach works relatively well and is useful in the context of Material Design interfaces. This article was a breakdown and example of how to pull off the same look and feel using vanilla JavaScript. As mentioned above you can get creative with a solution of your own, who knows maybe you'll stumble upon something superior to the iOS solution. If you have any thoughts feel free to reach out on Twitter @derekknox.