Animate an SVG viewBox with React
August 26, 2020
Backstory
I've been creating Developer Comics over the past 6 weeks and have been experimenting with various techniques as I create new comics. Last week I saw a tweet by Louis Hoebregts about animating the viewBox of an SVG and thought that'd be a great idea to apply to my next comic.
In this post I'll be animating the comic using a new version of Popmotion, which is a generic animation library, and also Framer Motion, which is built on top of Popmotion and is React specific.
Also, I wanted to support prefers-reduced-motion
so I pulled in the useMedia
React custom hook (written by Vadim Dalecky) so I could respond to the media query and toggle animation. I also added support to pause and resume animation on mouse enter and leave.
NOTE: If you want to learn more about
prefers-reduced-motion
, I have a blog post and free video showing how to toggle animations via different approaches (CSS, SVG SMIL, and JavaScript).
Pièce de Résistance
What's Behind the Curtain
As I mentioned above, I wanted to use the new Popmotion v9 release, which is currently in 9.0.0-rc.7
. Since it's still a Release Candidate, I referenced the docs for this release from GitHub.
Note: Since Popmotion is a general-purpose animation library, it is not specific to React. So I'll be using a React
ref
to directly communicate with the underlying SVG DOM to speed up the animation. Later in this post I'll show a Framer Motion version, which is a library that wraps Popmotion and is React specific.
Animating the viewBox with Popmotion
The Popmotion API is framework agnostic, so to optimize the speed of animation we'll create a React ref
and assign it to the SVG. Then we'll use a React.useEffect
to run after page render to kick off our animation. The important piece is the to
array that will animate from one viewBox to another. The animate
method will intelligently figure out the intermediate numbers between the various entries and call the onUpdate
method. Then we manually update the viewBox
using the React ref
.
NOTE: To pause between frames, I repeated the same
viewBox
twice, which is a common technique to pause a CSS animation.
import * as React from 'react';
import { animate, easeInOut } from 'popmotion';
export default function Comic() {
const svgRef = React.useRef(null);
React.useEffect(() => {
animate({
elapsed: -1500,
duration: 30000,
ease: easeInOut,
to: [
'0 0 1591 1788',
'58 155 735 735',
'58 155 735 735',
'800 155 735 735',
'800 155 735 735',
'58 900 735 735',
'58 900 735 735',
'800 900 735 735',
'800 900 735 735',
'0 0 1591 1788',
],
repeat: Infinity,
repeatDelay: 5000,
onUpdate: (val) => svgRef.current?.setAttribute('viewBox', val),
});
}, []);
return <svg ref={svgRef}>{/* ... */}</svg>;
}
NOTE: The underlying code for these examples can be found in a codesandbox where I take several approaches to animate the SVG viewBox.
prefers-reduced-motion
Support to Popmotion
Adding Although, we probably don't want to always animate the comic. Some users would prefer to not have animations. We can track this preference with the prefers-reduced-motion
media query along with the useMedia
custom React hook. If the user's preference is to reduce motion then we'll immediately stop the animation and reset the comic to the original zoomed-out viewBox, otherwise, we'll kick off the animation. Also, I added logic to stop the animation when the mouse is over the comic and it'll resume animation when the mouse leaves the comic.
import * as React from 'react';
import { animate, easeInOut } from 'popmotion';
import useMedia from 'use-media';
export default function Comic() {
/* ... */
const svgAnimate = React.useRef(null);
const reduceMotion = useMedia('(prefers-reduced-motion: reduce)');
React.useEffect(() => {
if (reduceMotion) {
svgAnimate.current?.stop();
svgRef.current?.setAttribute('viewBox', '0 0 1591 1788');
svgAnimate.current = null;
} else {
svgAnimate.current = animate({
/* ... */
});
}
}, [reduceMotion]);
return (
<svg
ref={svgRef}
{ /* ... */ }
>
{/* ... */}
</svg>
);
}
Pause and Resume animation with Popmotion
In case the animation is going too fast for the user, I thought I'd add a pause and resume feature when/if they moused over the comic. Thankfully, Popmotion made that easy by providing a stop
and play
method!
/* ... */
export default function Comic() {
/* ... */
return (
<svg
{ /* ... */ }
onMouseEnter={() => svgAnimate.current?.stop()}
onMouseLeave={() => svgAnimate.current?.play()}
>
{/* ... */}
</svg>
);
}
Using Framer Motion to Animate viewBox
I also tried using Framer Motion to perform the animation, which is a wrapper around Popmotion that is specific to React. Initially I found a bug that animated viewBox
as view-box
, which wouldn't accurately animate the SVG. Thankfully Matt Perry verified the issue and created a GitHub issue to track the bug and now it's fixed! Thanks Matt. The nice thing about using Framer Motion is that the API is very declarative which fits nicely when using React.
NOTE:
framer-motion
provides it's ownuseReducedMotion
custom React hook, so I didn't needuseMedia
.
import * as React from 'react';
import { motion, useReducedMotion } from 'framer-motion';
export default function SvgComponent() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.svg
animate={
shouldReduceMotion
? undefined
: {
viewBox: [
'0 0 1591 1788',
'58 155 735 735',
'58 155 735 735',
'800 155 735 735',
'800 155 735 735',
'58 900 735 735',
'58 900 735 735',
'800 900 735 735',
'800 900 735 735',
'0 0 1591 1788',
],
}
}
transition={{
duration: 30,
ease: 'easeInOut',
loop: Infinity,
repeatDelay: 5,
elapsed: -1.5,
}}
>
{/* ... */}
</svg>
);
}
NOTE: You might notice that I don't handle pause and resume in the
framer-motion
example. So far, I've not found a way that I can accomplish that like I did with Popmotion. I tried usinguseAnimation
, but it doesn't seem to allow the animation to be resumed after it has been stopped. I'll update the example if I find an approach.
Tweet about this post and have it show up here!