The Illusion Of Life: An SVG Animation Case Study

About The Author

Michael Ngo is an award winning front-end developer working across Sydney and Vancouver. He’s been nominated for a Webby, an AGDA design award, and his … More about Michael ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

How far can we push SVG animation? At the time, designer Chris Halaska and Michael Ngo were colleagues working on an illustration-heavy campaign website. While aesthetically pleasing, the designs lacked the required “oomph” that all creatives search for. Their idea was to create a data-driven process that enables designers to quickly prototype animations from static illustrations. In this article, Michael will take a look at how you can use SVGs to create seemingly complex animations from simple illustrations.

With flat design becoming the ever visible trend of 2016, it’s clear why there’s been a resurgence in SVG usage. The benefits are many: resolution-independence, cross-browser compatibility and accessible DOM nodes. In this article, we’ll take a look at how we can use SVGs to create seemingly complex animations from simple illustrations.

The Brief


Figure 1. What are we creating? Seemingly complex animations from simple SVG illustrations.

This project began as a simple thought experiment: How far can we push SVG animation?

At the time, designer Chris Halaska and I were colleagues working on an illustration-heavy campaign website. While aesthetically pleasing, the designs lacked the required “oomph” that all creatives search for. We found the answer in “The Camera Collection,” a motion graphics animation that had just gone viral. We could use animations to bring illustrations to life, and SVGs were the perfect medium to do this.

The problem we were facing, which still exists very much today, is that SVG animation is either delegated to a front-end developer who is attempting art direction or to a designer who is attempting JavaScript. Of course, neither of these scenarios is inherently wrong, but with animation being visual in nature and with very few applications tackling the problem, we wanted to bridge the gap between code and design.

Our idea was to create a data-driven process that enables designers to quickly prototype animations from static illustrations.

The Rules Of Animation

In The Illusion of Life, Disney outlines 12 basic principles to add character to animation. Squash and stretch, anticipation, slow in and slow out, timing, and exaggeration all serve to bring any object, inanimate or otherwise, to life. We wanted to follow these principles in our project, moving away from the rigidness of the DOM to something more fluid and natural. By creating a system around transformations, timing and easing, we were able to create animations that were stylistically uniform, but each with a character of its own.

Transformations

The flat design trend lends itself so well to SVG usage because of the simplicity of the illustrations. We mimicked this characteristic in animation, pairing geometric shapes with simple geometric movements. We had a single rule: use basic transformations (translate, rotate, scale) with basic origins (left, right, top, bottom and center).

transformations
Figure 2. The nine possible animation origins, using a combination of left, right, center, top and bottom.

Timing

To maintain a similar cadence and rhythm, we constrained ourselves to very specific increments of time. Animations lasted 2 seconds and comprised 10 individual steps. A tween, an animation of just a single transformation (translate, rotate and scale) had to begin and end on one of these steps, which we defined as keyframes.

timing
Figure 3. An example animation in which each step increments by 200 milliseconds with three overlapping tweens.

Easing

While transformations and timing are enough to create the visual perception of motion, easing brings everything to life. We found that three easing formulas provided more than enough variation to add character to the movements: easeOutBack, easeInOutBack and easeOutQuint.

easing
Figure 4. A visual comparison of animations with and without easing. Note that using any variation of easingBack will influence the transformations to some extent.

Let’s Get Started

Preparing The Assets

Though the illustration app landscape has matured in recent years, with Sketch and Inkscape becoming increasingly popular, we opted to create our SVGs in Adobe Illustrator.

breakdown
Figure 5. A breakdown of the elements that make up the final animation.
illustrator layers
Figure 6. Illustrator automatically creates IDs from layer names when exporting to SVG.

Before you export to SVG, group and label every layer. Illustrator will automatically creates IDs from the layer names during the exporting process. For every animated element, the output should look similar to the XML shown below. Note that even if an element doesn’t have any children, it still needs to be grouped under a g tag. This is in preparation for adding SVG transform groups, explained later on.

<g id="zipper">
<path fill="#272C40" d="…"/>
</g>
illustrator exporting
Figure 7. The SVG export settings used. We unchecked “responsive” because the animation units are pixel-based.

Handling Masks

You may have noticed the <Clip Group> layer in figure 6. These are essentially clipping masks created in Illustrator. When exported to SVG, they automatically become predefined clipPaths that can be used to mask elements in the exact same way.

<g>
<defs>
  <rect id="SVGID_1_" x="235" y="-106.3" width="500" height="309"/>
</defs>

<clipPath id="SVGID_2_">
  <use xlink:href="#SVGID_1_"  overflow="visible"/>
</clipPath>

<g id="strap-right" clip-path="url(#SVGID_2_)">
  <path fill="#93481F" stroke="#000000" stroke-width="1.5" stroke-miterlimit="10" d="…"
    />
</g>
</g>
clipPath
Figure 8. The use of clipPath to hide the belt straps before animation.

Prototype, Prototype, Prototype

With the assets prepared, we were ready to build. We began an iterative process of creating prototypes and testing various technologies to find a solution. Here, we’ll briefly outline each of our attempts, the pros and cons, and why we pivoted from one solution to the next.

CSS And Velocity.js

Our initial attempts at using CSS to create animations were promising. We believed that with hardware-accelerated transformations, the animations would run smoothly and the implementation would be straightforward, without the need for external libraries. While we were able to create a functioning version in Chrome, the solution failed in all other browsers.

Firefox would not respect the transform-origin property of SVGs, while Internet Explorer’s support for SVG CSS animation is completely non-existent. Lastly, with CSS and JavaScript being so tightly coupled, we found ourselves jumping back and forth between too many files for the solution to be considered elegant.

In a similar vein, we ran into the same problems with Velocity.js. Because the animation engine also uses CSS transformations, the Firefox and Internet Explorer issues remained unresolved.

GSAP

GSAP has been an industry standard since its Flash days, and its popularity has risen even more so since being ported to JavaScript. With its chainable syntax, extensive SVG support and unparalleled performance, GSAP was an obvious contender — save for one issue: It was overkill. Importing TweenMax and TimelineMax immediately doubled the size of our project and proved to be excessive. Chris Gannon let us know that TimelineMax is included in TweenMax and combined is only 37kb, a misunderstanding on our end.

Snap.svg

In our final attempt, we used Snap.svg, the successor to Raphael. Snap offers extensive functionality in DOM manipulation but the bare minimum in animation support. Though we recognized this as a setback, the deficiencies led us to roll our own JavaScript to fill in the gaps. This resulted in a lightweight solution that was still capable of achieving the fidelity of animations we were striving for.

Mo.js, Anime And Web Animations API

Since writing this article, three very promising SVG animation libraries have been gaining traction in the community: Mo.js, Anime and the Web Animations API. If we get the chance to revisit the problem, these alternatives would definitely be taken into consideration. Nonetheless, the concepts behind this article should be transferable to any animation library you wish to use.

The Scaffold

We’ll begin by importing a basic style sheet and the Snap.svg library into our project. We’ll also include a port of Robert Penner’s easing functions for later use.

folder structure
Figure 9. The final folder structure of our project. The “Hello world” scaffold begins with just the highlighted files.
<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>The Illusion of Life: An SVG Animation Case Study</title>

  <!-- Styles -->
  <link rel="stylesheet" type="text/css" href="css/style.css" />

  <!-- Libraries -->
  <script src="js/libs/snap.svg.min.js"></script>
  <script src="js/libs/snap.svg.easing.min.js"></script></html>
</head>
</html>
/* Full screen */
html, body {
  position: relative;
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
  background-color: #E6E6E6;
  font-family: sans-serif;
}

/* Centered canvas */
#canvas {
  position: absolute;
  top: 50%;
  left: 50%;
  -webkit-transform: translateX(-50%) translateY(-50%);
  -ms-transform: translateX(-50%) translateY(-50%);
  transform: translateX(-50%) translateY(-50%);
  overflow: hidden;
}

Hello World

“Hello world” — a small, simple win. For us, that just meant getting something on the screen. We first instantiated a new Snap object, with a DOM ID representing our canvas. We use the Snap.load function to indicate the external SVG source and an anonymous callback that will append the nodes to the DOM tree.

<body>
<div id="canvas"></div>

<script>
  (function() {
    var s = Snap('#canvas');

    Snap.load("svg/backpack.svg", function (data) {
      s.append(data);
    });
  })();
</script>
</body>

Making A Simple Plugin

To create a reusable component for multiple animations, we create a “plugin” using the prototype pattern. Using an immediately invoked function expression (IIFE) ensures data encapsulation, while still adding SVGAnimation to the global namespace. If we place the code we have so far into a separate init function, we will have the basis for SVGAnimation.

; (function(window) {
'use strict';

var svgAnimation = function () {
  var self = this;
  self.init();
};

svgAnimation.prototype = {
  constructor: svgAnimation,

  init: function() {
    var s = Snap('#canvas');

    Snap.load("svg/backpack.svg", function (data) {
      s.append(data);
    });
  }
};

// Add to global namespace
window.svgAnimation = svgAnimation;
})(window);

See the Pen Simple Plugin by Michael Ngo (@hellomichael) on CodePen.

Adding Options

Dissecting Snap.load, we can see two potential parameters that can be passed in as options, a canvas and an external SVG source. Let’s create a separate loadSVG function to handle just that.

/*
Loads the SVG into the DOM
@param {Object}   canvas
@param {String}   svg
*/
loadSVG: function(canvas, data) {
Snap.load(svg, function(data) {
  canvas.append(svg);
});
}

Objects As Parameters

Now we need a way to pass these options into SVGAnimation. There are several ways to do this, the standard way being to pass individual parameters.

var backpack = new svgAnimation(Snap('#canvas'), 'svg/backpack.svg');

But there’s a better solution. By passing in objects instead, the code becomes not only more readable, but also more flexible. We no longer need to keep track of the order; we can make parameters optional; and we can also reuse the object later. So, let’s rewrite the previous snippet, passing in an options object instead.

var backpack = new svgAnimation({
canvas:       new Snap('#canvas'),
svg:          'svg/backpack.svg'
});

Merging Objects

Now that we have the options object, we need to make the values accessible to the rest of the plugin. But before we do this, let’s merge the passed-in object with our own defaults. Even though we’ve chosen to set both values to null, we’ll still include them as a reference for the type of values we expect to receive.

svgAnimation.prototype = {
constructor: svgAnimation,

options: {
  canvas:     null,
  svg:        null
}
};

With the defaults now set, we’ll use an extend function to merge both objects. Essentially, the function will loop through all of the properties of one object and copy them over to another.

/*
Merges two objects
@param  {Object}  a
@param  {Object}  b
@return {Object}  sum
https://stackoverflow.com/questions/11197247/javascript-equivalent-of-jquerys-extend-method
*/
function extend(a, b) {
for (var key in b) {
  if (b.hasOwnProperty(key)) {
    a[key] = b[key];
  }
}

return a;
}

With the extend function defined, let’s amend the SVGAnimation constructor. One thing you’ll notice is that self is set to this. We’ll cache the original this to ensure that inner scopes have access to the current object’s data and methods.

var svgAnimation = function (options) {
var self = this;
self.options = extend({}, self.options);
extend(self.options, options);
self.init();
}

Lastly, we’ll update init to call loadSVG, passing in the canvas and svg reference we set during instantiation.

init: function() {
var self = this;

self.loadSVG(self.options.canvas, self.options.svg);
}

See the Pen Adding Options by Michael Ngo (@hellomichael) on CodePen.

Hardcoded Prototype

Adding SVG Transformation Groups

As mentioned earlier, Snap.svg’s animation engine is quite primitive and, just like CSS, only supports transform strings as a single request. This means that if you’re looking to animate more than one type of transformation, it must happen either sequentially or all at once (sharing duration and easing). Though not the most elegant solution, adding extra nodes to the DOM tree solves this problem. With a separate grouped element for each translate, rotate and scale transformation, we can now independently control each tween. The example that best illustrates this use case is the zipper, which also serves as our initial prototype.

We begin by passing the zipper element to the createTransformGroup function, which we then go on to define.

var $zipper = canvas.select("#zipper");
self.createTransformGroup($zipper);

After we’ve selected all child nodes, we can use the Snap.g function to nest the contents within each respective transform group.

/*
Create scale, rotate and transform groups around an SVG DOM node
@param {object} Snap element
*/

createTransformGroup: function(element) {
if (element.node) {
  var childNodes = element.selectAll('*');

  element.g().attr('class', 'translate')
    .g().attr('class', 'rotate')
    .g().attr('class', 'scale')
    .append(childNodes);
}
}

This results in the creation of independent transformation groups, which we can target in our animations.

<!-- Old node -->
<g id="zipper">
<path fill="#272C40" d="…"/>
</g>

<!-- New node -->
<g id="zipper">
<g class="translate">
  <g class="rotate">
    <g class="scale">
      <path fill="#272C40" d="…"></path>
    </g>
  </g>
</g>
</g>

A Snap.svg Animation

We’re finally ready to animate our first element. Snap.svg provides two functions to do this: transform and animate. We’ll use transform to place the animation at the first keyframe, and then use animate to get us to the last.

Snap.svg supports standard SVG transform notation, but we’ve opted to use transform strings as a means to set parameters instead. Explanations are sparse on the official website, but legacy documentation can be found on Raphael’s. The initial uppercase letter is an abbreviation of the transformation. The parameters x, y and angle represent the values we are animating to, with cx and cy being the center of origin.

// Scale
Snap.animate({transform: 'S x y cx cy'}, duration, easing, callback);

// Rotation
Snap.animate({transform: 'R angle cx cy'}, duration, callback);

// Translate
Snap.animate({transform: 'T x y'}, duration, callback);

Calculating Origins

We ran into an interesting problem with defining origins, however. In Snap.svg, the animate and transform functions only accept parameters as pixel values, making it extremely difficult to measure. Ideally, as our brief outlined, we wanted to define the origin as a combination of top, right, bottom, left and center.

Fortunately, Snap.svg provides getBBox, which measures the bounding box of any given element, returning a multitude of descriptors, including the values we’re searching for. We’ll write two functions, getOriginX and getOriginY, that accept a bBox object and a direction string as parameters, returning pixel values as needed.

/*
Translates the horizontal origin from a string to pixel value
@param {Object}     Snap bBox
@param {String}     "left", "right", "center"
@return {Object}    pixel value
*/

getOriginX: function (bBox, direction) {
if (direction === 'left') {
  return bBox.x;
}

else if (direction === 'center') {
  return bBox.cx;
}

else if (direction === 'right') {
  return bBox.x2;
}
},

/*
Translates the vertical origin from a string to pixel value
@param {Object}     Snap bBox
@param {String}     "top", "bottom", "center"
@return {Object}    pixel value
*/

getOriginY: function (bBox, direction) {
if (direction === 'top') {
  return bBox.y;
}

else if (direction === 'center') {
  return bBox.cy;
}

else if (direction === 'bottom') {
  return bBox.y2;
}
}

Animation In Practice

Let’s see this all in practice with a scaling animation. We first select the corresponding transform group using its class name, scale it down until it’s hidden, and then animate it back to its original size. You’ll notice that we are scaling from the top of the zipper, with a duration of 400 milliseconds, and setting the custom easing to easeOutBack.

// Scale Tween
var $scaleElement = $zipper.select('.scale');
var scaleBBox = $scaleElement.getBBox();
$scaleElement.transform('S' + 0 + ' ' + 0 + ' ' + self.getOriginX(scaleBBox, 'center') + ' ' + self.getOriginY(scaleBBox, 'top'));
$scaleElement.animate({transform: 'S' + 1 + ' ' + 1 + ' ' + self.getOriginX(scaleBBox, 'center') + ' ' + self.getOriginY(scaleBBox, 'top')}, 400, mina['easeOutBack']);

Rotation follows the same pattern, with a few complexities. In this case, we have three tweens that play consecutively. When each animation is finished, we use its callback function to play the next animation in queue.

// Rotate Tween
var $rotateElement = $zipper.select('.rotate');
var rotateBBox = $rotateElement.getBBox();
$rotateElement.transform('R' + 45 + ' ' + rotateBBox.cx + ' ' + rotateBBox.cy);

$rotateElement.animate({ transform: 'R' + -60 + ' ' + self.getOriginX(rotateBBox, 'center') + ' ' + self.getOriginY(rotateBBox, 'top')}, 400, mina['easeOutBack'], function() {
$rotateElement.animate({ transform: 'R' + 30 + ' ' + self.getOriginX(rotateBBox, 'center') + ' ' + self.getOriginY(rotateBBox, 'top')}, 400, mina['easeOutBack'], function() {
  $rotateElement.animate({ transform: 'R' + 0 + ' ' + self.getOriginX(rotateBBox, 'center') + ' ' + self.getOriginY(rotateBBox, 'top')}, 400, mina['easeInOutBack']);
});
});

The translate tween mimics both scale and rotation, with one key difference. Because the translate animation doesn’t begin immediately, we use setTimeout to delay the starting time by 400 milliseconds.

// Translate Tween
var $translateElement = $zipper.select('.translate');
$translateElement.transform('T' + 110 + ' ' + 0);

setTimeout(function() {
$translateElement.animate({ transform: 'T' + 0 + ' ' + 0 }, 600, mina['easeOutQuint']);
}, 400);

See the Pen Hard-coded Prototype by Michael Ngo (@hellomichael) on CodePen.

Keyframes Are Key

At this point, you might be wondering, “Well, that was fairly complex for such a simple animation.” We wouldn’t disagree.

Our goal was to create a data-driven process to rapidly prototype animations. By creating a separate tween class and introducing the concept of keyframes, we can go from code like this…

// Translate Tween
var $translateElement = $zipper.select('.translate');
$translateElement.transform('T' + 110 + ' ' + 0);

setTimeout(function() {
$translateElement.animate({ transform: 'T' + 0 + ' ' + 0 }, 600, mina['easeOutQuint']);
}, 400);

… to code like this:

// Translate Tween
new svgTween({
element: $zipper.select('.translate'),
keyframes: [
  {
    "step": 2,
    "x": 110,
    "y": 0
  },

  {
    "step": 5,
    "x": 0,
    "y": 0,
    "easing": "easeOutQuint"
  }
],
duration: 2000/10
});

As we subdivide each animation into individual steps, we begin to see how this format might make prototyping easier. Let’s break down the parameters of this translate tween and explain where these numbers come from.

In our original code, you might have noticed that the durations and delays were all divisible by a factor of 200 milliseconds. That wasn’t a coincidence. If the entirety of an animation lasts 2000 milliseconds and consists of 10 steps, we simply need to divide the former by the latter to calculate the duration of a single step. We can now use the same logic to determine why the keyframes start at step 2 and end at step 5. The setTimeout, which lasts 400 milliseconds, corresponds to two steps, the initial delay. Furthermore, the duration of the animation is 600 milliseconds, which is calculated to be three steps, the difference between steps 2 and 5.

svgTween: Translate

With the output of our black box defined, let’s write the functionality for the SVGTween class. Using the same pattern as SVGAnimation, we’re able to quickly flesh out a basic scaffold.

/*
svgTween.js v1.0.0
Licensed under the MIT license.
https://www.opensource.org/licenses/mit-license.php

Copyright 2015, Smashing Magazine
https://www.smashingmagazine.com/
https://www.hellomichael.com/
*/

; (function(window) {
'use strict';

var svgTween = function (options) {
  var self = this;
  self.options = extend({}, self.options);
  extend(self.options, options);
  self.init();
};

svgTween.prototype = {
  constructor: svgTween,

  options: {
    element:    null,
    keyframes:  null,
    duration:   null
  },

  init: function () {
    var self = this;
  }
};

/*
  Merges two objects
  @param {Object} a
  @param {Object} b
  @return {Object} sum
  https://stackoverflow.com/questions/11197247/javascript-equivalent-of-jquerys-extend-method
*/
function extend(a, b) {
  for (var key in b) {
    if (b.hasOwnProperty(key)) {
      a[key] = b[key];
    }
  }

  return a;
}

// Add to namespace
window.svgTween = svgTween;
})(window);

Using the same algorithm as before, we’ll set the animation to the first initial hidden state, then animate from there. Instead of using Snap.svg’s transform and animate functions, we’ll rewrite them as resetTween and playTween to handle keyframes instead.

resetTween will accept an element and the keyframes array. The only difference is that, instead of directly setting the values in the transform string, we’ll use the values in the first keyframe.

/*
Resets the animation to the first keyframe

@param {Object} element
@param {Array}  keyframes
*/
resetTween: function (element, keyframes) {
var self = this;

var translateX = keyframes[0].x;
var translateY = keyframes[0].y;

element.transform('T' + translateX + ',' + translateY);
}

Because Snap.svg doesn’t provide chainable animation methods, we’ll have to use callbacks for consecutive animations.

Snap.animation(attr, duration, [easing], [callback]);

However, this instantly becomes unruly if we have more than two keyframes, essentially sending us into a form of callback hell. To handle this problem, we’ll implement playTween as a recursive function, allowing us to loop through animations without necessarily having to nest them.

Let’s start by defining the parameters of our animation. Just as with resetTween, we’ll set the values in our transform string to the keyframe values. Easing is done very much in the same way. Duration is either set to the pause leading up to the first animation or calculated as the span of time between steps.

/*
Recursively loop through keyframes to create pauses or tweens

@param {Object} element
@param {Array}  keyframes
@param {Int}    duration
@param {Int}    index
*/
playTween: function(element, keyframes, duration, index) {
var self = this;

// Set keyframes we’re transitioning to
var translateX = keyframes[index].x;
var translateY = keyframes[index].y;

// Set easing parameter
var easing = mina[keyframes[index].easing];

// Set duration as an initial pause or the difference of steps between keyframes
var newDuration = index ? ((keyframes[index].step - keyframes[(index-1)].step) * duration) : (keyframes[index].step * duration);
}

With the parameters prepared, let’s write conditional statements that pause, play or kill an animation. Our first conditional statement checks whether the animation begins immediately at step 0. If it does, we’ll move on, because the transform function already handles this first keyframe. If we tried to animate to the same values as resetTween, we would sometimes see a brief flicker, a bug that took us ages to find. The next two conditional statements check whether we should delay the animation or begin playing tweens. The one thing to note is the use of nested conditional statements that check whether the recursive function should fire again. Without them, playTween could run indefinitely.

// Play first tween immediately if starts on step 0
if (index === 0 && keyframes[index].step === 0) {
self.playTween(element, keyframes, duration, (index + 1));
}

// Or pause tween if initial keyframe
else if (index === 0 && keyframes[index].step !== 0) {
setTimeout(function() {
  if (index !== (keyframes.length - 1)) {
    self.playTween(element, keyframes, duration, (index + 1));
  }
}, newDuration);
}

// Or animate tweens if keyframes exist
else {
element.animate({
  transform: 'T' + translateX + ' ' + translateY
}, newDuration, easing, function() {
  if (index !== (keyframes.length - 1)) {
    self.playTween(element, keyframes, duration, (index + 1));
  }
});
}

The last step is to update our init function to call resetTween and playTween.

init: function () {
var self = this;

self.resetTween(self.options.element, self.options.keyframes);
self.playTween(self.options.element, self.options.keyframes, self.options.duration, 0);
}

See the Pen svgTween - Translate by Michael Ngo (@hellomichael) on CodePen.

svgTween: Rotation And Scale

With our zipper now moving from right to left, it’s time to add rotation and scale to the mix. Let’s amend our options to include type, originX and originY. Because svgTween will now handle all transformations, we’ll include a type variable to specify which one. We’ll also track originX and originY to set the correct transform-origins for scale and rotation. Translation is never affected by transform-origin, so it is always set to center center by default.

options: {
element:    null,
type:       null,
keyframes:  null,
duration:   null,
originX:    null,
originY:    null
}

Let’s update resetTween and playTween to handle these new values. We’ll first check the type and then construct the respective transform strings. We’ll create separate translateX, translateY, rotationAngle, scaleX and scaleY variables, so that it is visually identifiable how our transform strings are generated.

/*
Resets the animation to the first keyframe

@param {Object} element
@param {String} type - "scale", "rotate", "translate"
@param {Array}  keyframes
@param {String} originX - "left", "right", "center"
@param {String} originY - "top", "bottom", "center"
*/
resetTween: function (element, type, keyframes, originX, originY) {
var transform, translateX, translateY, rotationAngle, scaleX, scaleY;

if (type === 'translate') {
  translateX = keyframes[0].x;
  translateY = keyframes[0].y;
  transform = 'T' + translateX + ' ' + translateY;
}

else if (type === 'rotate') {
  rotationAngle = keyframes[0].angle;
  transform = 'R' + rotationAngle + ' ' + originX + ' ' + originY;
}

else if (type === 'scale') {
  scaleX = keyframes[0].x;
  scaleY = keyframes[0].y;
  transform = 'S' + scaleX + ' ' + scaleY + ' ' + originX + ' ' + originY;
}

element.transform(transform);

We’ll mimic the same pattern in playTween, replacing the relevant index from the recursive function. We’ll also update the function calls with the new type, originX and originY parameters.

/*
Recursively loop through keyframes to create pauses or tweens

@param {Object} element
@param {String} type - "scale", "rotate", "translate"
@param {Array}  keyframes
@param {String} originX - "left", "right", "center"
@param {String} originY - "top", "bottom", "center"
@param {Int}    duration
@param {Int}    index
*/
playTween: function(element, type, keyframes, originX, originY, duration, index) {
var self = this;

// Set keyframes we're transitioning to
var transform, translateX, translateY, rotationAngle, scaleX, scaleY;

if (type === 'translate') {
  translateX = keyframes[index].x;
  translateY = keyframes[index].y;
  transform = 'T' + translateX + ' ' + translateY;
}

else if (type === 'rotate') {
  rotationAngle = keyframes[index].angle;
  transform = 'R' + rotationAngle + ' ' + originX + ' ' + originY;
}

else if (type === 'scale') {
  scaleX = keyframes[index].x;
  scaleY = keyframes[index].y;
  transform = 'S' + scaleX + ' ' + scaleY + ' ' + originX + ' ' + originY;
}

// Set easing parameter
var easing = mina[keyframes[index].easing];

// Set duration as an initial pause or the difference of steps between keyframes
var newDuration = index ? ((keyframes[index].step - keyframes[(index-1)].step) * duration) : (keyframes[index].step * duration);

// Skip first tween if animation immediately starts on step 0
if (index === 0 && keyframes[index].step === 0) {
  self.playTween(element, type, keyframes, originX, originY, duration, (index + 1));
}

// Or pause tween if initial keyframe
else if (index === 0 && keyframes[index].step !== 0) {
  setTimeout(function() {
    if (index !== (keyframes.length - 1)) {
      self.playTween(element, type, keyframes, originX, originY, duration, (index + 1));
    }
  }, newDuration);
}

// Or animate tweens if keyframes exist
else {
  element.animate({
    transform: transform
  }, newDuration, easing, function() {
    if (index !== (keyframes.length - 1)) {
      self.playTween(element, type, keyframes, originX, originY, duration, (index + 1));
    }
  });
}
}

Lastly, we’ll update our init function to set type, originX and originY, before calling resetTween and playTween. We can set type simply by adopting the class of the passed-in element. At this point, we can transfer over getOriginX and getOriginY from SVGAnimation. We then use a ternary operator to set our origin, defaulting to center if the values are undefined.

init: function () {
var self = this;

// Set type
self.options.type = self.options.element.node.getAttributeNode('class').value;

// Set bbox to specific transform element (.translate, .scale, .rotate)
var bBox = self.options.element.getBBox();

// Set origin as specified or default to center
self.options.originX = self.options.keyframes[0].cx ? self.getOriginX(bBox, self.options.keyframes[0].cx) : self.getOriginX(bBox, 'center');
self.options.originY = self.options.keyframes[0].cy ? self.getOriginY(bBox, self.options.keyframes[0].cy) : self.getOriginY(bBox, 'center');

// Reset and play tween
self.resetTween(self.options.element, self.options.type, self.options.keyframes, self.options.originX, self.options.originY);
self.playTween(self.options.element, self.options.type, self.options.keyframes, self.options.originX, self.options.originY, self.options.duration, 0);
}

Let’s finalize our zipper animation by instantiating new tweens for both rotation and scale. As with translate, we can calculate the keyframes and duration by the number of steps and overall length of the animation. In reality, we defined all of these parameters much more organically: by viewing the animations as they progressed and constantly fine-tuning the numbers.

// Rotate tween
new svgTween({
element: $zipper.select('.rotate'),
keyframes: [
  {
    "step": 0,
    "angle": 45,
    "cy": "top"
  },

  {
    "step": 2,
    "angle": -60,
    "easing": "easeOutBack"
  },

  {
    "step": 4,
    "angle": 30,
    "easing": "easeOutQuint"
  },

  {
    "step": 6,
    "angle": 0,
    "easing": "easeOutBack"
  }
],
duration: duration
});
// Scale tween
new svgTween({
element: $zipper.select('.scale'),
keyframes: [
  {
    "step": 0,
    "x": 0,
    "y": 0,
    "cy": "top"
  },

  {
    "step": 2,
    "x": 1,
    "y": 1,
    "easing": "easeOutBack"
  }
],
duration: duration
});

See the Pen svgTween: Rotation and Scale by Michael Ngo (@hellomichael) on CodePen.

JSON Config

The very last step of our build is to extract the hardcoded values from SVGAnimation and add them to our constructor instead. Let’s add the keyframes, duration and number of steps in the instantiation.

(function() {
var backpack = new svgAnimation({
  canvas:       new Snap('#canvas'),
  svg:          'svg/backpack.svg',
  data:         'json/backpack.json',
  duration:     2000,
  steps:        10
});
})();

By passing in a JSON file to define keyframes, a designer can immediately create a prototype without having to dive into documentation. In fact, this concept could be completely library-agnostic if you replace Snap.svg with GSAP, Mo.js or the Web Animations API.

The JSON file is formatted into separate tweens, consisting of element IDs and keyframes. We include the zipper animation as an example, but the backpack.json file includes arrays for all of the elements (zipper, pockets, logo, etc.).

{
"animations": [
  {
    "id": "#zipper",
    "keyframes": {
      "translateKeyframes": [
        {
          "step": 6,
          "x": 110,
          "y": 0
        },

        {
          "step": 9,
          "x": 0,
          "y": 0,
          "easing": "easeOutQuint"
        }
      ],

      "rotateKeyframes": [
        {
          "step": 4,
          "angle": 45,
          "cy": "top"
        },

        {
          "step": 6,
          "angle": -60,
          "easing": "easeOutBack"
        },

        {
          "step": 8,
          "angle": 30,
          "easing": "easeOutQuint"
        },

        {
          "step": 10,
          "angle": 0,
          "easing": "easeOutBack"
        }
      ],

      "scaleKeyframes": [
        {
          "step": 4,
          "x": 0,
          "y": 0,
          "cy": "top"
        },

        {
          "step": 6,
          "x": 1,
          "y": 1,
          "easing": "easeOutBack"
        }
      ]
    }
  }
]
}
options: {
data:                 null,
canvas:               null,
svg:                  null,
duration:             null,
steps:                null
}

The details of how to load a JSON file are beyond the scope of this article, but what’s significant is the use of a callback function to return the JSON data for future use — in our case, passing the animations array to loadSVG.

/*
Get JSON data and populate options
@param {Object}   data
@param {Function} callback
*/
loadJSON: function(data, callback) {
var self = this;

// XML request var xobj = new XMLHttpRequest(); xobj.open(‘GET’, data, true);

xobj.onreadystatechange = function() { // Success if (xobj.readyState === 4 && xobj.status === 200) { var json = JSON.parse(xobj.responseText);

if (callback &amp;&amp; typeof(callback) === &quot;function&quot;) {
  callback(json);
}

} };

xobj.send(null); }

We’re now able to update loadSVG to loop through our animations array, creating svgTweens dynamically. If any of translateKeyframes, rotateKeyframes or scaleKeyframes are defined, we instantiate a new svgTween, passing in the keyframes and duration from our options file.

loadSVG: function(canvas, svg, animations, duration) {
var self = this;

Snap.load(svg, function(data) {
  // Placed SVG into the DOM
  canvas.append(data);

  // Create tweens for each animation
  animations.forEach(function(animation) {
    var element = canvas.select(animation.id);

    // Create scale, rotate and transform groups around an SVG node
    self.createTransformGroup(element);

    // Create tween based on keyframes
    if (animation.keyframes.translateKeyframes) {
      self.options.tweens.push(new svgTween({
        element: element.select('.translate'),
        keyframes: animation.keyframes.translateKeyframes,
        duration: duration
      }));
    }

    if (animation.keyframes.rotateKeyframes) {
      self.options.tweens.push(new svgTween({
        element: element.select('.rotate'),
        keyframes: animation.keyframes.rotateKeyframes,
        duration: duration
      }));
    }

    if (animation.keyframes.scaleKeyframes) {
      self.options.tweens.push(new svgTween({
        element: element.select('.scale'),
        keyframes: animation.keyframes.scaleKeyframes,
        duration: duration
      }));
    }
  });
});
}

Finally, we update our init function to call loadJSON, which in turn calls loadSVG, finishing our tutorial for good.

init: function() {
var self = this;

self.loadJSON(self.options.data, function (data) {
  self.loadSVG(self.options.canvas, self.options.svg, data.animations, (self.options.duration/self.options.steps));
});
}

See the Pen JSON Config by Michael Ngo (@hellomichael) on CodePen.

A Note On Performance

Our goal was to see how far we could push SVG animation; so, we favored animation fidelity over performance. We stand by this because it enabled us to push our animations much further than anticipated. However, we didn’t ignore performance completely.

Looking at the Chrome DevTools timeline, we see that the animation plays at a steady 60 frames per second, with a few hiccups in between. If we break down the backpack animation, there are 19 elements with 3 possible transforms. That means, at worst, there are 57 possible tweens happening at once. Fortunately, this isn’t the case because the tweens are staggered over the lifetime of the animation. We can visually see this in the CPU graph, as its usage steadily ramps up, peaks where the animations overlap the most, and then diminishes as each tween ends. Visually, Firefox and Internet Explorer were able to play the animations with no discernible differences in performance.

desktop performance
Figure 10. The Chrome DevTools timeline, showing CPU usage and the frame rate for desktop.

As expected, mobile devices took a performance hit. Using remote debugging on an old Android device, our frame rate dropped from 60 per second, hovering between 30 and 60. Though not perfect, we felt this was more than satisfactory for our needs. There is a silver lining, though, because our latest tests on an iPhone 5 and iPhone 6 performed flawlessly.

mobile performance
Figure 11. Remote debugging on Android, showing weaker performance on mobile.

What’s Next?

Unfortunately, the campaign was dropped before completion, so we never had a chance to dive deeper into the project. As is, the source code provided isn’t quite production-ready; we would have liked to have addressed a few key issues.

Event-Driven

Our Codepen embed provides a “rerun” button, but our implementation isn’t event-driven. Ideally, the animations wouldn’t immediately play back until initiated via some type of interaction (mouse click, waypoint, etc.).

Mobile Devices

While these animations do run on mobile devices, as mentioned, they are processor-heavy. So, consider their importance in the overall design of your project. Performance and file size could be saved significantly by excluding them. If they’re an absolute necessity, consider further how they could be made responsive for mobile viewports.

Fallbacks

The solution for our animations works in all modern browsers and has been tested in Internet Explorer 9+, Firefox and Chrome. This is primarily due to Snap.svg support. If your project requires the use of older browsers, you could try using Snap.svg’s predecessor, Raphael. The more accessible approach is progressive enhancement, serving a static SVG initially and then adding animation for those with capable browsers.

Signing Off

Well, there you have it, from simple illustration to complex animation. You can download the entire code base on GitHub.

Backpack Full Animation

Last but not least, a big thank you to Rey Bango of the Smashing Magazine team, Chris Halaska for the amazing illustrations, Matt Harwood for the code review, and Rhiana Chan for the much-needed editing.

Further Reading

Smashing Editorial (rb, ml, al, il, mrn)