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.
Example
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:
- Mask (aka Clipping Area or Window)
- Content
- Content Container (aka Wrapper)
- Scrolling (aka Panning)
- Scrolling Boundaries
- Tracked User Input
- 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.
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:
- Listen for down input
- When down input occurs, start to listen for move and up input
- When move input occurs, record data associated with the movement
- Analyze that data to determine what needs updated (visually or programmatically)
- When up input occurs, stop listening for move and up input
- 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.