CompanyArticlesOpen SourceServices
 

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:

Clicking anywhere deconstructs the logo to reveal the site content. Clicking the tiny logo at the bottom reconstructs the animation.

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:

  1. Open your favorite SVG editor and make an image,
  2. Manipulate the image inside the SVG editor to build "key frames",
  3. Allow the code to interpolate the difference between the key frames to achieve a smooth animation?
Clicking the above image causes the emoji to oscillate between bored, happy and silly. He should probably see a therapist. Clicking the image again will stop the 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.

Each emotion is represented by a group of paths within the same SVG image.

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>
AMD, CommonJS and ES6 import methods are all supported in addition to a simple global include.

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.

Interpolation value of 2... as in 200% of the way between bored and happy.

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>
AMD, CommonJS and ES6 import methods are all supported in addition to a simple global include.

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;
}
Clicking the face animates the emoji from bored to happy.

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 and keyFramePaths.
  • 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);
Clicking the face loops the emoji between bored, happy and silly. Clicking again stops the animation.

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);
};
Clicking the image shows why linear interpolation doesn't work for rotation.

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);
};
Clicking the image shows how to properly combine rotation with linear interpolation.

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.

Comments