DEV Community

Tom Dohnal
Tom Dohnal

Posted on • Edited on • Originally published at tomdohnal.com

React SVG Animation (with React Spring) #4

In the fourth edition of the React SVG Animation series, we'll learn how to create this 👇

a face animating from disappointed to neutral and then to excited

(You can find a video version of this article on YouTube! 📺)

We're going to implement it by animating morphing SVG paths. (It sounds fancier than it should.) The code itself is going to be pretty straightforward but we'll need to spend some time in a graphic editor (like Figma) to get our SVG ready.

(Full source code available on CodeSandbox)

Table of Contents

  1. How Does the Animation Work in Theory?
  2. How to Prepare the SVG for the Animation? (optional)
  3. How to morph SVG paths using React Spring?
  4. Bonus: How to Morph SVG Paths with a Different Number of Points?



How Does the Animation Work in Theory?

Let's say we've got this SVG (it look kinda like a frowning mouth) 👇
SVG path which looks like a frowning mouth

And we want to animate it into this smiling mouth
SVG path which looks like a smiling mouth

Both the frowning and smiling mouths are represented as SVG <path> elements.

The shape of the path is determined by the d attributes. It looks like this: d="M2 2 C18 26 68 62 126 62 C188 62 238 26 254 2" and you can think of it as a series of points (denoted by the numbers) which are connected via different types of lines/curves (denoted by the letters).

In order to animate from one shape to another, we need to animate all the "numbers" in the path definition (the d attribute). Luckily, this is quite easy with a library like React Spring.

If we have a path definition of a smile and of a frown, we can do something like this:

const smilePathDefinition = "M2 2 C18 26 68 62 126 62 C188 62 238 26 254 2"
const frownPathDefinition = "M2 2 C18 -26 68 -62 126 -62 C188 -62 238 -26 254 2"

const animatedProps = useSpring({
  d: shouldSmile ? // `shouldSmile` can be accepted as props or held in local state
    smilePathDefinition : frownPathDefinition
})

return <svg /* ... */>
  <path d={animatedProps.d} /* ... */ />
</svg>
Enter fullscreen mode Exit fullscreen mode

Have a look at this video to get a better understanding of what's going on:
a smile animating to a frown back and forth

In order for the animation to work properly, the path definitions (the d attributes) must have the same amount of points ("numbers"). (There are some workarounds but let's keep it simple now)



How to Prepare the SVG for the Animation? (optional)

If you're only interested in the coding part of the tutorial, you can skip this section.

As described in the previous section, the animation itself is pretty straightforward in terms of coding. What's a bit more difficult is putting together the SVG Paths in a way that they all have the same amount of points so that they can animate from one to the other.

In order to ensure the same number of points, we'll have to spend some time in a graphics editor of our choice–Figma.

First, we'll need to find the images of the emojis that we'll be animating. We can do that on Emojipedia where you can search for different types of emojis in different "flavours". I generally find the Twitter emojis to be the most simple (thus the most suitable for animations).

You can now download the emojis that we'll be using: disappointed 😞, neutral 😐, and excited 😍

Once you've downloaded the images, open Figma and place all the images next to each other:
emoji images placed next to each other in Figma

Next comes the hardest part of the tutorial. We'll need to use the pen tool in Figma and manually trace the paths of our emoji images in a way that every part which morphs into another part has the same number of points. This means that the eyes in all three emojis should all have the same number of points and the mouths should all have the same number of points as well.

In order to achieve that, let's first copy all three images, decrease their opacity and lock them:
figma screenshot with the steps described above

Then, you should trace the edges of the eyes and mouth of the emojis using the pen tool in Figma. Try to use as few points as possible–it will be easier that way. (Four points worked for me.)
tracing edges of the eyes

Instead of tracing both eyes, do it just once and then copy your path and flip it horizontally.

Do the same for the mouth:
tracing edges of the eyes

When you're done, copy both the eyes and the mouth, place them over the other two images and adjust the bezier curve handles accordingly while trying to keep the points as close to their original location as possible.

You should end up with something like this:
image with paths traced

We've already done the hardest part! Now we need to prepare the emojis for SVG exports.

Let's add a circle according to the template for each of them:
emojis with circle

Then, let's fill the eyes and mouths with colours according to the template and remove the stroke:
emojis with stroke removed and filled colour

Group the emojis so that they're ready for export
emoji grouped

And, finally, export the images as SVGs!
exporting images as SVGs



How to Morph SVG Paths Using React Spring?

Now that we've got the SVG images, let's dive into the code!

First, we'll bootstrap a new react project (e. g. using create-react-app) and add the react-spring library using a dependency manager of our choice.

Next, let's add the SVG of the disappointed face into the code:

function App() {
  return (
    <svg
      width="240"
      height="240"
      viewBox="0 0 240 240"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <circle cx="120" cy="120" r="120" fill="#FFCD4C" />
      <path
        d="M192 135.5C188 135.5 178.5 126 164.5 123C150.5 120 141 125.5 137 123C133 120.5 152 99.0001 172 104.5C192 110 196 135.5 192 135.5Z"
        fill="#664300"
      />
      <path
        d="M48.3939 135.897C52.3939 135.897 61.8939 126.397 75.8939 123.397C89.8939 120.397 99.394 125.896 103.394 123.397C107.394 120.897 88.3939 99.3966 68.3939 104.897C48.3939 110.397 44.3939 135.897 48.3939 135.897Z"
        fill="#664300"
      />
      <path
        d="M119.5 162.5C145 162.5 159.5 189.5 155 194.5C150.5 199.5 153 188.5 119.5 188.5C86 188.5 87.5002 197 84.0001 194.5C80.5001 192 94.0001 162.5 119.5 162.5Z"
        fill="#664300"
      />
    </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

We can see that the SVG comprises of four different elements.
1) The circle element: The head of the emoji
2) The first path element: The left eye of the emoji
3) The second path element: The right eye of the emoji
4) The third path element: The mouth of the emoji

If we take a look at the neutral SVG and the cheerful SVG, we can see that they follow the same pattern–one circle element for the head and three path elements for the mouth and eyes respectively.

Moreover, the circle element is completely the same for all three emojis as their heads don't change among themselves.

If you take a closer look at the path elements, they all are very similar. The only difference is the d attribute which defines the shape of the path. And that is the attribute we're going to focus on and animate.

Animating the Emoji Mouth

Let's first focus on animating the emoji mouth. First, let's extract all the path definitions (the d attributes) defining the mouths in the above-mentioned SVG files into an array. The first array item should be the frowning mouth, the second one the neutral mouth, and the last one the cheerful mouth

const mouths = [
  "M119.5 162.5C145 162.5 159.5 189.5 155 194.5C150.5 199.5 153 188.5 119.5 188.5C86 188.5 87.5002 197 84.0001 194.5C80.5001 192 94.0001 162.5 119.5 162.5Z",
  "M122.501 160C148.001 160 173.001 159.5 173.001 167.5C173.001 175.5 156.001 173 122.501 173C89.0008 173 66.5 175 66.5 167.5C66.5 160 97.0006 160 122.501 160Z",
  "M120.999 145C158.499 145 171.5 138 176 142.5C187.5 154 164.499 198.5 120.999 198.5C77.4995 198.5 54.929 161.5 62 145C66.2855 135 83.5 145 120.999 145Z"
];
Enter fullscreen mode Exit fullscreen mode

For some reason, Figma added an unnecessary H122.501 at the end of the second path definition in the original SVG. I removed it in the code above as it would introduce and extra point to the path thus breaking the animation.

Then, we'll need to import animated and useSpring from the react-spring library:

import { animated, useSpring } from "react-spring";
Enter fullscreen mode Exit fullscreen mode

Next, let's change all path elements to animated.path so that they're compatible with React Spring animations:

<animated.path /* ... */ />
<animated.path /* ... */ />
<animated.path /* ... */ />
Enter fullscreen mode Exit fullscreen mode

We'll also need to keep track of which emoji should be displayed–let's import and use a useState(...) hook to do that. We'll use numbers 0, 1, and 2 to indicate which emoji should be shown. This is going to correspond to the index of the active item in the mouths array:

// ...
import { useState } from "react";

// ...

function App() {
  const [activeIndex, setActiveIndex] = useState(0);

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll need buttons to make switching between the different emojis possible. The buttons are just going to call setActiveIndex(...).

function App() {
  // ...

  return (
    <div>
      {/* ... */}
      <div>
        {["disappointed", "neutral", "excited"].map((text, index) => (
          <button
            type="button"
            key={index}
            onClick={() => {
              setActiveIndex(index);
            }}
            style={{
              background: activeIndex === index ? "purple" : "white",
              color: activeIndex === index ? "white" : "black"
            }}
          >
            {text}
          </button>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally, the animation itself! Let's first add a useSpring call. We'll the current mouth path as { mouth: mouths[activeIndex] }. React Spring can handle animating between path definitions the same way it can handle animating properties like translate(...).

// ...

function App() {
  // ...
  const animationProps = useSpring({
    mouth: mouths[activeIndex]
  });

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Next, let's replace the hard-coded path definition with the one we get as animationProps.mouth:

// ...

function App() {
  // ...

  return (
    <div>
      <svg /* ... */>
        {/* ... */}
        <animated.path d={animationProps.mouth} /* ... */ />
      </svg>
      {/* ... */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you now click the buttons, the mouth will be animating. Yay! 🙌
mouth animating between sad, neutral, and happy

You can find the code for this section on CodeSandbox

Animating the Emoji Eyes

Now that we know how to animate the emoji mouths, we'll use the very same approach for animating the emoji eyes as well.

We'll use two array to hold the path definitions of the left and right emoji eyes:

// ...
const leftEyes = [
  "M48.3939 135.897C52.3939 135.897 61.8939 126.397 75.8939 123.397C89.8939 120.397 99.394 125.896 103.394 123.397C107.394 120.897 88.3939 99.3966 68.3939 104.897C48.3939 110.397 44.3939 135.897 48.3939 135.897Z",
  "M60.4997 109.5C60.5744 123 64.9997 132.5 76.9996 132.5C88.9996 132.5 92.4998 124.5 92.4997 112C92.4996 99.5 86.9998 87 76.9996 87C66.9995 87 60.4249 96 60.4997 109.5Z",
  "M10.3812 33.1735C-0.61879 51.1735 4.38095 95.6736 81.3809 107.174C125.381 63.1733 117.381 18.1732 97.3811 7.67327C77.3814 -2.82666 57.8813 11.1733 53.3809 24.6736C47.3812 20.6735 21.3812 15.1736 10.3812 33.1735Z"
];

const rightEyes = [
  "M192 135.5C188 135.5 178.5 126 164.5 123C150.5 120 141 125.5 137 123C133 120.5 152 99.0001 172 104.5C192 110 196 135.5 192 135.5Z",
  "M179.5 109.5C179.575 123 174.963 133.5 164 133.5C153.037 133.5 147 122 147 109.5C147 97 153.5 87 164 87C174.5 87 179.425 96 179.5 109.5Z",
  "M230 32.5002C241 50.5002 236 95.0003 159 106.5C115 62.5 123 17.4999 143 6.99994C163 -3.5 182.5 10.5 187 24.0002C193 20.0002 219 14.5002 230 32.5002Z"
];
// ...
Enter fullscreen mode Exit fullscreen mode

On top of animating the path definitions, we'll also be animating the colour of the eyes as the cheerful emoji has got red eyes. Let's grab the colour hex codes from the SVGs and store them in an array as well:

// ...
const eyeColours = ["#664300", "#664300", "#DE2A42"];
// ...
Enter fullscreen mode Exit fullscreen mode

Let's now leverage our newly created arrays in the useSpring(...) function:

// ...

function App() {
  // ...
  const animationProps = useSpring({
    // ...
    leftEye: leftEyes[activeIndex],
    rightEye: rightEyes[activeIndex],
    eyeColour: eyeColours[activeIndex]
  });

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Finally, pass the newly created animationProps onto the respective SVG paths:

// ...

function App() {
  // ...

  return (
    <div>
      <svg /* ... */>
        {/* ... */}
        <animated.path
          d={animationProps.rightEye}
          fill={animationProps.eyeColour}
        />
        <animated.path
          d={animationProps.leftEye}
          fill={animationProps.eyeColour}
        />
        {/* ... */}
      </svg>
      {/* ... */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you now hit save, it's safe to say, I think, that we're done! ✅
emoji animating between sad, neutral, and cheerful

You can find the code for this section on CodeSandbox



Bonus: Animating SVG Paths with a Different Number of Points

So far, we've focused on how to animate SVG paths with the same number of points. Although the animation itself was quite straightforward, it required a non-trivial effort to put the SVGs together in our vector editor (Figma).

In this section, we'll learn how to create the same emoji animation that we've created in the section above without the need to use of any vector editing software.

It won't come without a cost, though. It's going to require a bit more of a programming effort.

How Do Custom Interpolators Work?

Let's first define what we mean be the interpolator.

For our purposes, an interpolator is a function that can be called with a value from 0 to 1. If you call it with 0, you get back the path definition of the original element. If you call it with 1, you get back the path definition of the element you're trying to morph into. If you call it with 0.5, you get back the path definition of a shape that's halfway through its animation journey. And so on. 🙂

We'll use the flubber library in order to create these interpolator functions. This library enables us to create these function if we provide it the path definitions of the shapes from and to we want to animate.

The "magic" part is that the library doesn't require the path definitions to have the same number of points as it's going to do its magic (maths 🙂) and return an interpolator which we can use to create a smooth animation.

Let's Dive Into the Code

We're going to begin where we left off and install flubber.

Next, we need to replace our carefully crafted SVG for the ones from the Twemoji Github.

Open the disappointed emoji, the neutral emoji, and the excited emoji.

Next, replace the path definitions in the mouths, leftEyes, and rightEyes array for the ones you find in the above-mentioned emojis.

Use tools like SVG Path Editor to convert relative paths to absolute ones or Ellipse to Path Converter to turn ellipses into paths. Also, you'll need to split some of the path definitions into parts by finding where each segment of the path ends (marked by the letter Z in the path).

const mouths = [
  "M 23.485 28.879 C 23.474 28.835 22.34 24.5 18 24.5 S 12.526 28.835 12.515 28.879 C 12.462 29.092 12.559 29.31 12.747 29.423 C 12.935 29.535 13.18 29.509 13.343 29.363 C 13.352 29.355 14.356 28.5 18 28.5 C 21.59 28.5 22.617 29.33 22.656 29.363 C 22.751 29.453 22.875 29.5 23 29.5 C 23.084 29.5 23.169 29.479 23.246 29.436 C 23.442 29.324 23.54 29.097 23.485 28.879 Z",
  "M25 26H11c-.552 0-1-.447-1-1s.448-1 1-1h14c.553 0 1 .447 1 1s-.447 1-1 1z",
  "M18 21.849c-2.966 0-4.935-.346-7.369-.819-.557-.106-1.638 0-1.638 1.638 0 3.275 3.763 7.369 9.007 7.369s9.007-4.094 9.007-7.369c0-1.638-1.082-1.745-1.638-1.638-2.434.473-4.402.819-7.369.819"
];

const leftEyes = [
  "M 11.226 15.512 C 10.909 15.512 10.59 15.551 10.279 15.628 C 7.409 16.335 6.766 19.749 6.74 19.895 C 6.7 20.118 6.816 20.338 7.021 20.435 C 7.088 20.466 7.161 20.482 7.232 20.482 C 7.377 20.482 7.519 20.419 7.617 20.302 C 7.627 20.29 8.627 19.124 10.996 18.541 C 11.71 18.365 12.408 18.276 13.069 18.276 C 14.173 18.276 14.801 18.529 14.804 18.53 C 14.871 18.558 14.935 18.57 15.011 18.57 C 15.283 18.582 15.52 18.349 15.52 18.07 C 15.52 17.905 15.44 17.759 15.317 17.668 C 14.95 17.233 13.364 15.512 11.226 15.512 Z",
  "M9,16.5a2.5,3.5 0 1,0 5,0a2.5,3.5 0 1,0 -5,0",
  "M 16.65 3.281 C 15.791 0.85 13.126 -0.426 10.694 0.431 C 9.218 0.951 8.173 2.142 7.766 3.535 C 6.575 2.706 5.015 2.435 3.541 2.955 C 1.111 3.813 -0.167 6.48 0.692 8.911 C 0.814 9.255 0.976 9.574 1.164 9.869 C 3.115 13.451 8.752 15.969 12.165 16 C 14.802 13.833 17.611 8.335 16.883 4.323 C 16.845 3.975 16.77 3.625 16.65 3.281 Z"
];

const rightEyes = [
  "M 24.774 15.512 C 25.091 15.512 25.41 15.551 25.721 15.628 C 28.591 16.335 29.234 19.749 29.26 19.895 C 29.3 20.118 29.184 20.338 28.979 20.435 C 28.912 20.466 28.839 20.482 28.768 20.482 C 28.623 20.482 28.481 20.419 28.383 20.302 C 28.373 20.29 27.373 19.124 25.004 18.541 C 24.29 18.365 23.592 18.276 22.931 18.276 C 21.827 18.276 21.2 18.529 21.196 18.53 C 21.129 18.558 21.065 18.57 20.99 18.57 C 20.718 18.582 20.481 18.349 20.481 18.07 C 20.481 17.905 20.561 17.759 20.684 17.668 C 21.05 17.233 22.636 15.512 24.774 15.512 Z",
  "M22,16.5a2.5,3.5 0 1,0 5,0a2.5,3.5 0 1,0 -5,0",
  "M 19.35 3.281 C 20.209 0.85 22.875 -0.426 25.306 0.431 C 26.782 0.951 27.827 2.142 28.235 3.535 C 29.426 2.706 30.986 2.435 32.46 2.955 C 34.89 3.813 36.167 6.48 35.31 8.911 C 35.187 9.255 35.026 9.574 34.837 9.869 C 32.886 13.451 27.249 15.969 23.835 16 C 21.198 13.833 18.39 8.335 19.118 4.323 C 19.155 3.975 19.23 3.625 19.35 3.281 Z"
];
Enter fullscreen mode Exit fullscreen mode

The eyeColours array stays the same.

You might now see something like this and wonder if it's broken:
broken image on an emoji

To fix this, change the viewBox to 0 0 36 36 which is the viewbox used in the original emoji. Also, we'll need to change the parameters of the circle used for the emoji head.

// ...

function App() {
  // ...

  return (
    {/* ... */}
      <svg viewBox="0 0 36 36" /* ... */ >
        <circle cx="18" cy="18" r="18" fill="#FFCD4C" />
        {/* ... */}
      </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's dive into the nitty-gritty of the interpolators.

We'll keep track of the interpolators in the local state (using useState(...)). We'll use three interpolators–one for animating the mouth and one for each of the eyes.

When we click the button to trigger the animation, we'll update the interpolator state to hold the specific interpolators for the animations. For example–if the current state of the emoji is "dissapointed" and we want it to animate to "neutral", we'll update the interpolator state to this exact interpolators. We'll then use the useSpring(...) to animate from 0 to 1 to trigger the animation.

First, initialise the interpolator state:

// ...

function App() {
  // ...
  const [interpolators, setInterpolators] = useState({
    mouth: () => mouths[activeIndex],
    leftEye: () => leftEyes[activeIndex],
    rightEye: () => rightEyes[activeIndex],
  });
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Their initial state is set to "trivial" interpolators–they return the same paths no matter what number we supply to the function.

Next, we need to make some adjustments to the useSpring(...) function. We'll always be animating from 0 to 1, which needs to be reflected in the from and to options. We'll also set the clamp to true which will prevent the animated number from overreaching 1 which would break our interpolators. Finally, we'll set the reset option to true to make the animated value always go back to 0.

// ...

function App() {
  // ...
  const animationProps = useSpring({
    from: { x: 0 },
    to: {
      x: 1,
      eyeColour: eyeColours[activeIndex], // this stays the same as it doesn't need a custom interpolator
    },
    config: {
      clamp: true, // interpolation function can't go above 1
    },
    reset: true,
  });
  // ...
}
Enter fullscreen mode Exit fullscreen mode

We'll then adjust the animated.path elements to make use of the interpolators. We'll leverage to .to(...) function which enables us to pass an interpolator function to interpolate an animated value:

// ...

function App() {
  // ...

  return (
    <div>
      <svg /* ... */>
        {/* ... */}
        <animated.path
          d={animationProps.x.to(interpolators.rightEye)}
          // ...
        />
        <animated.path
          d={animationProps.x.to(interpolators.leftEye)}
          // ...
        />
        <animated.path
          d={animationProps.x.to(interpolators.mouth)}
          // ...
        />
      </svg>
      {/* ... */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to adjust the onClick button handler. The gist of is that we want to create new interpolators from the previous value of activeIndex to the new value of activeIndex on the button click.

In order to do that, we'll pass a callback to the setActiveIndex function in which we'll update the interpolators using the interpolate(...) function from the flubber library.

// ...
import { interpolate } from "flubber";

function App() {
  // ...

  return (
    <div>
      {/* ... */}
      <div>
        {["disappointed", "neutral", "excited"].map((text, index) => (
          <button 
            // ...
            onClick={() => {
              setActiveIndex((prevIndex) => {
                setInterpolators({
                  mouth: interpolate(mouths[prevIndex], mouths[index], {
                    maxSegmentLength: 0.5
                  }),
                  rightEye: interpolate(
                    rightEyes[prevIndex],
                    rightEyes[index],
                    {
                      maxSegmentLength: 0.5
                    }
                  ),
                  leftEye: interpolate(leftEyes[prevIndex], leftEyes[index], {
                    maxSegmentLength: 0.5
                  })
                });

                return index;
              });
            }}
            // ...
          >
            {/* ... */}
          </button>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The maxLengthSegment ensure the smoothness of the animation. The lower the number, the smoother it is. But be careful–it comes at a cost of performance.

We're done! 🥂 Here's the final result:
emoji animating from disappointed to neutral to cheerful

You can find the code for this section on CodeSandbox

Top comments (1)

Collapse
 
dharld profile image
Dharld

I liked the serie. It was quite interesting 🙏