Animating SVGs with Metamorpher and VelocityJS
With support for inline SVGs ubiquitous among modern browsers, it's time to start to use them for more than just images on a page. One such usage is animation that feels like it's part of the page - SVG elements interacting with DOM elements because they are indeed part of the DOM.
The intro animation for agencyautonomous.com uses SVGs in just this way:
Now, before we get too far into this tutorial, let it be noted that most SVG animation can be achieved with pure CSS transforms and transitions, and this is how it should be. Animation is inherently describing how the a page should look at different points in time, so, if at all possible, we should use CSS. This includes translation, scaling, skewing, rotation and perspective.
It's a pretty comprehensive list! However, this approach has two drawbacks:
- You cannot fundamentally alter or morph the shapes into something else
- You must know how to achieve your desired effect by applying the functions listed above
Instead, wouldn't it be nice to be able to:
- Open your favorite SVG editor and make an image,
- Manipulate the image inside the SVG editor to build "key frames",
- Allow the code to interpolate the difference between the key frames to achieve a smooth animation?
TL;DR
If you'd rather dig into the completed examples than read through the tutorial, you can download the sample.zip and view live versions of morph and rotate-morph.
Preparing the SVG
The animated emoji is achieved using three states (the key frames) loaded into the same SVG. To get started, download the emoji SVG and include it inline with the rest of the HTML.
If you're using a templating language, you can include/import the SVG file directly. For a real project, this is highly recommended so you don't have a massive SVG cluttering up your HTML.
If you're not using a templating language, simply copy and paste the contents of the SVG into your HTML. You can omit <?xml version="1.0" standalone="no"?>
but it won't harm anything if it is included in the HTML.
<html>
<body>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 145 130" width="145" height="130">
<defs>
<clipPath id="_clipPath_AMJp5PVwh3ISv8zxhQr8vai49I6bCSk0">
<rect width="145" height="130" />
</clipPath>
</defs>
<g id="face" clip-path="url(#_clipPath_AMJp5PVwh3ISv8zxhQr8vai49I6bCSk0)">
...
</g>
</svg>
</body>
</html>
At first, the image will show all three emotions at once.
For ease of reference, each key frame is surrounded in a group tag and given a class name for the emotion. For more complex animations, these class names would be more like keyframe-1
or, even better, data attributes. IDs should be avoided here, as they can have some weird side effects if the SVG is included multiple times on a page.
<svg>
<g class="bored">
<path d="..." />
<path d="..." />
</g>
<g class="happy">
<path d="..." />
<path d="..." />
</g>
<g class="silly">
<path d="..." />
<path d="..." />
</g>
</svg>
Since we only want to display the first key frame before the animation begins, the other key frames are hidden with simple CSS. In fact, the hidden groups will never be displayed! Rather, their path coordinates will be copied over to the first key frame's paths at the appropriate time.
.happy, .silly {
display: none;
}
Before we add any JavaScript, this results in a simple bored emoji.
Transforming the Image
Before animating the image we should introduce Metamorpher and understand how it will morph one key frame into the next. To do this we'll setup a small test which will illustrate the transform, interpolate and paint methods.
Note: all of the examples in this article use ES6 syntax to be forward looking but each can easily accomplished using ES5 syntax (classic JavaScript).
Metamorpher Installation
The best way to install Metamorpher is to use the yarn (or npm) package manager.
yarn add metamorpher
If not using a package manager, download the compiled source.
After obtaining the package, include the script in your project. Note that Metamorpher exposes several utilities for working with SVG paths, but we'll only need the Path
class, so we'll pull that class out.
<script src="./metamorpher.js"></script>
<script>
let Path = metamorpher.Path;
</script>
Managing Path Data With Metamorpher
To allow Metamorpher to transform SVG path data, we can simply pass the path DOM element into the new Path(element)
constructor. However, each emotion key frame contains multiple paths to transform, so create the following convenience function, getFramePaths
, to return an array of Path
elements for each key frame by css selector.
To ensure that this function is able to find the paths in the DOM, make sure you add the JS code at the end of your document or otherwise wait for the document to be ready.
let getFramePaths = (selector) => {
let group = document.querySelector(selector);
let domPaths = group.querySelectorAll('path');
return Array.from(domPaths).map(path => {
return new Path(path);
});
};
Interpolating between two images
To demonstrate interpolation, we're going to modify the image to be half-way between the bored and happy key frames.
let initialPaths = getFramePaths('.bored');
let startPaths = getFramePaths('.bored');
let endPaths = getFramePaths('.happy');
initialPaths.forEach((path, index) => {
path.interpolate(
startPaths[index],
endPaths[index],
0.5
).paint();
});
The interpolate
method takes three arguments: the start path and end path for interpolation and a decimal between 0 and 1 representing a percentage of interpolation between the two paths. Note that the the DOM isn't actually updated until the paint
method is called, allowing multiple transformations to be applied before updating the DOM.
The resulting image is a slightly excited emoji.
Try playing with different values for the interpolation amount decimal (0.5) to test the different effects of interpolation.
Not exactly an obvious use of interpolation (since the value should be between 0 and 1) but useful for some animation easings such as easeInOutBack.
Animating the Image
Believe it or not, the hard part is already done! We could get a pretty crude animation by setting progressively higher interpolation values on a delay. However, introducing an animation library will give us the following niceties:
- Better timing control
- Easings to make the start and stop look more natural
- The ability to mix CSS animation with Metamorpher path transformations
- Cleaner code
There are a few packages out there, but I prefer VelocityJS for the performance and non-reliance on other packages.
VelocityJS Installation
The best way to install VelocityJS is to use the yarn (or npm) package manager.
yarn add velocity-animate@1.5.1
If not using a package manager, download the compiled source.
After obtaining the package, include the script in your project.
<script src="./velocity.js"></script>
First Animation
To pull off the animation, we're only going to use a small piece of VelocityJS - it's timing and tweening. To do this, we need to define a progress
function which will instruct VelocityJS on how the image should look at each point during the animation. The progress
function takes 5 arguments, but we're only concerned with the last one, tween
.
let initialPaths = getFramePaths('.bored');
let startPaths = getFramePaths('.bored');
let endPaths = getFramePaths('.happy');
let progress = (e, c, r, s, tween) => {
initialPaths.forEach((path, index) => {
path.interpolate(
startPaths[index],
endPaths[index],
tween
).paint();
});
}
To begin the animation, we'll initialize Velocity through its main function. The first argument, element
, is the element being animated by Velocity. We're actually not having Velocity perform any transformations - Metamorpher will perform that task for us. So for now, you can just pass in the body element.
The second argument, properties
, normally would contain the Velocity transformations, but we simply need to instruct Velocity to tween between 0 and 1.
The last argument, options
, is where we'll specify the animation timing, easing and, most importantly, the progress function to invoke the Metamorpher transformations.
let element = document.querySelector('body');
let properties = { tween: 1 };
let options = {
duration: 600,
easing: 'easeInOut',
progress: progress
}
return Velocity(element, properties, options);
To pull the animation together, we need to attach it to an event. To do that, surround the last two code snippets in a function (leave initialPaths
out of the function) and attach it to the click event on the face
class.
let initialPaths = getFramePaths('.bored');
let startPaths = getFramePaths('.bored');
let endPaths = getFramePaths('.happy');
let animate = () => {
let progress = (e, c, r, s, tween) => {
initialPaths.forEach((path, index) => {
path.interpolate(
startPaths[index],
endPaths[index],
tween
).paint();
});
};
let element = document.querySelector('body');
let properties = { tween: 1 };
let options = {
duration: 600,
easing: 'easeInOut',
progress: progress
}
return Velocity(element, properties, options);
};
document.querySelector('.face')
.addEventListener('click', animate);
Additionally, you'll want the cursor to change to a pointer when the user hovers over the face to indicate an action.
svg, .face:hover {
cursor: pointer;
}
Looping the Animation Between Three Key Frames
To complete the animation, we need to loop the animation between each key frame. First, define the following before the animate function:
- The initial paths,
- A key frame paths array,
- A current frame tracker,
- An animating boolean and
- A variable to hold a reference to a
setTimeout
.
let initialPaths = getFramePaths('.bored');
let keyFramePaths = [
getFramePaths('.bored'),
getFramePaths('.happy'),
getFramePaths('.silly')
];
let currentFrame = 0;
let animating = false;
let animationTimeout;
let animate = () => {
...
Next, modify the animate function to:
- Determine the startPaths and endPaths based on
currentFrame
andkeyFramePaths
. - Increment the currentFrame with each function call.
- After the animation completes, call the animate function again after a short delay if the
animating
boolean is still set. This is easy to do since Velocity returns a promise which resolves when the animation completes.
let animate = () => {
let startPaths = keyFramePaths[currentFrame % keyFramePaths.length];
let endPaths = keyFramePaths[(currentFrame + 1) % keyFramePaths.length];
currentFrame++;
let progress = (e, c, r, s, tween) => {
initialPaths.forEach((path, index) => {
path.interpolate(startPaths[index], endPaths[index], tween).paint();
});
};
let element = document.querySelector('body');
let properties = { tween: 1 };
let options = {
duration: 600,
easing: 'easeInOut',
progress: progress
}
return Velocity(element, properties, options).then(() => {
if (animating) {
animationTimeout = setTimeout(animate, 400);
}
});
};
Lastly, pull it all together by introducing a function to toggle the animation on or off and update the click handler to use the toggle function.
let toggleAnimation = (method) => {
animating = !animating;
if (animating) {
animate();
}
else {
clearTimeout(animationTimeout);
}
};
document.querySelector('.face')
.addEventListener('click', toggleAnimation);
Common Issues
Rotation
This article describes how to achieve technique of linear interpolation using key frames. While this is an effective approach for many transformations, it doesn't work as may be expected for rotated images. This is because linear interpolation will cause each point to take the shortest path from start to finish. To demonstrate this, let's take our emoji friend for a quick spin when he smiles.
Note: the rotation examples make use of the Point
class from Metamorpher to specify the rotation anchor. So if you're following along, you'll need to save that class from the package as well.
let Point = metamorpher.Point;
Using the Metamorpher rotate method, we can turn the endPaths
270 degrees clockwise and try the interpolation again.
let initialPaths = getFramePaths('.bored');
let midPoint = new Point(72.5, 65);
let startPaths = getFramePaths('.bored');
let endPaths = getFramePaths('.happy').map(path => {
return new Path(path).rotate(270, midPoint);
});
let animate = () => {
let progress = (e, c, r, s, tween) => {
initialPaths.forEach((path, index) => {
path.interpolate(
startPaths[index],
endPaths[index],
tween
).paint();
});
};
let element = document.querySelector('body');
let properties = { tween: 1 };
let options = {
duration: 600,
easing: 'easeInOut',
progress: progress
}
return Velocity(element, properties, options);
};
In the above demonstration, the face ends up in the correct position, but it got there by rotating 90 degrees counter-clockwise and shrinking slightly along the way. This happened because that's the shortest linear path for rotating 270 degrees clockwise.
To rotate properly, we simply can't use linear interpolation. Instead, we must manually figure out the proper rotation to apply on top of the normal interpolation from bored to happy.
let initialPaths = getFramePaths('.bored');
let midPoint = new Point(72.5, 65);
let startPaths = getFramePaths('.bored');
let endPaths = getFramePaths('.happy');
let animate = () => {
let progress = (e, c, r, s, tween) => {
initialPaths.forEach((path, index) => {
path
.interpolate(
startPaths[index],
endPaths[index],
tween
)
.rotate(270 * tween, midPoint)
.paint();
});
};
let element = document.querySelector('body');
let properties = { tween: 1 };
let options = {
duration: 600,
easing: 'easeInOut',
progress: progress
}
return Velocity(element, properties, options);
};
Only Paths
It's important to note that SVGs are usually made up of more than just <path>
elements - <circle>
, <line>
and <rect>
are common among many more shapes. Metamorpher, however, only supports transformation of <path>
s. Fortunately, every vector image program can easily convert shapes and text into paths.
Re-using SVGs on a Page
If you've included the same SVG multiple times on a page and you're updating the path data, you may notice some browsers update each instance of the SVG path data in the rendering, even though you explicitly only updated a single instance. What's happening here is that the browser is internally relying on the uniqueness of the ID attributes in the SVG. This can be fixed with the SVG IDs package.
Final Thoughts
Hopefully this tutorial not only taught you something, but got you thinking about what you can create with this technique. The basics of key frame animation with interpolation and rotation allow you to create professional animations with relatively few hours of work. I'm interested to see what you do with the technique - let me know in the comments section or drop me a line at jeff@agencyautonomous.com.