Animate an SVG viewBox with React

Published

August 26, 2020

Reading time
6 min read

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.

Adding prefers-reduced-motion Support to Popmotion

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 own useReducedMotion custom React hook, so I didn't need useMedia.

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 using useAnimation, 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.

Web Mentions
0
0

Tweet about this post and have it show up here!