DEV Community

Cover image for Exit animation with `framer-motion` demystified
Romain Trotard
Romain Trotard

Posted on • Updated on

Exit animation with `framer-motion` demystified

Unlike Svelte which has built-in animation and transition, React does not.
If you have worked with animation in React, you probably faced the problem of not being able to animate easily a component that will unmount.

function App() {
  const [shouldShow, setShouldShow] = useState(true);

  // Do some animation when unmounting
  const onExitAnimation = ...;

  return shouldShow ? (
    <div onExit={onExitAnimation}>
      Animated when unmounting
    </div>
  ) : (
    <p>No more component</p>
  );
}
Enter fullscreen mode Exit fullscreen mode

For example, when working with react-spring, you have to pass your state to the useTransition hook that will give you a new variable to use.
You can't directly condition the display of your component with the shouldShow state.
This way react-spring manages this state internally to change it when the component has finished the animation.

function App() {
  const [shouldShow, setShouldShow] = useState(true);
  const transitions = useTransition(shouldShow, {
    leave: { opacity: 0 },
  });

  return transitions(
    (styles, show) =>
      // Here we do not use directly `shouldShow`
      show && (
        <animated.div style={styles}>
          Animated when unmounting
        </animated.div>
      )
  );
}
Enter fullscreen mode Exit fullscreen mode

To me it doesn't feel natural.

When I finally decided to take a look at framer-motion, it was a real pleasure when I discovered the AnimatePresence component that handles it more naturally for me.

Note: As always, the code I will write is simplified compared to the library code.


Exit animation with framer-motion

Let's start by looking at the code to do such animation with framer-motion.

It's pretty simple to do this animation:

import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";

export default function App() {
  const [show, setShow] = useState(true);

  return (
    <>
      <button type="button" onClick={() => setShow(!show)}>
        Show / Unshow
      </button>
      <AnimatePresence>
        {show ? (
          <motion.p exit={{ opacity: 0 }}>
            Animated content
          </motion.p>
        ) : null}
      </AnimatePresence>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note: I just configure the exit but you can also configure the "enter" transition thanks to intial and animate prop.

Crazy simple. But how do they manage to do this exit animation? Have you an idea? Just two words React ref :)


Under the hood

Make enter and exit animation

As you have seen in the previous example of framer-motion you can access to an object named motion. From it, you can get your animated elements on which you can use the props initial, animate and exit.

Own implementation specification

  • make a motion object which has a key p that returns a React component to do animation
  • this component have two public props named onEnter to animate when mounting and onExit to animate when unmounting
  • use the animation web API

Note: If you want to know more about the animation web API you can read my article Let's do some animations in native Javascript

Let's trigger the enter and exit animation thanks to an useEffect. We get the following implementation for AnimatedComponent and motion:

const AnimatedComponent =
  (Tag) =>
  ({ onExit, onEnter, ...otherProps }) => {
    const elementRef = useRef(null);

    useEffect(() => {
      const animation = elementRef.current.animate(
        onEnter,
        {
          duration: 2000,
          fill: "forwards",
        }
      );

      return () => {
        const animation = elementRef.current.animate(
          onExit,
          {
            duration: 2000,
            fill: "forwards",
          }
        );
        animation.commitStyles();
      };
      // I don't include onEnter and onExit as dependency
      // Because only want them at mount and unmount
      // Could use references to satisfy the eslint rule but
      // too much boilerplate code
    }, []);

    return <Tag {...otherProps} ref={elementRef} />;
  };

const motion = {
  p: AnimatedComponent("p"),
};
Enter fullscreen mode Exit fullscreen mode

Unfortunately if we try this implementation the exit animation will not work :(

Why is it complicated to do such animation?

The reason is because when a component is no more in the React tree, it's directly removed from the DOM tree too.

How to solve this?

The idea is to trigger the animations thanks to a property isVisible.

const AnimatedComponent =
  (Tag) =>
  ({ onExit, onEnter, isVisible, ...otherProps }) => {
    const elementRef = useRef(null);

    useEffect(() => {
      if (isVisible) {
        const animation = elementRef.current.animate(
          onEnter,
          {
            duration: 2000,
            fill: "forwards",
          }
        );

        return () => animation.cancel();
      } else {
        const animation = elementRef.current.animate(
          onExit,
          {
            duration: 2000,
            fill: "forwards",
          }
        );
        animation.commitStyles();

        return () => animation.cancel();
      }
    }, [isVisible]);

    return <Tag {...otherProps} ref={elementRef} />;
  };
Enter fullscreen mode Exit fullscreen mode

But we do not want the user to handle the isVisible property. Moreover the component needs to stay in the React tree to work.

It's here that comes the AnimatePresence component that will keep the unmounted children in a reference and at each render detects components that are removed.

In order to do that, we need to be able to distinguish each children components. We are going to use key for that.

Detection of removed elements

Things you need to know

  • React.Children.forEach utility function that allows us to loop through all children
  • React.isValidElement function that allows us to validate that we have a React element
  • the key is at the first level of ReactElement and not in props!

Get all valid children

Let's do a function to get all valid children components:

function getAllValidChildren(children) {
  const validChildren = [];

  React.Children.forEach(children, (child) => {
    if (React.isValidElement(child)) {
      validChildren.push(child);
    }
  });

  return validChildren;
}
Enter fullscreen mode Exit fullscreen mode

Keep children of previous render

As I said previously, we are going to keep children of the previous render thanks to React reference.

If you want to know more about the usage of React ref, you can see my article Things you need to know about React ref.

import { useRef, useLayoutEffect } from "react";

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });
}
Enter fullscreen mode Exit fullscreen mode

Get key of children and determine removed keys

Now let's write the method to get the key of a React element:

function getKey(element) {
  // I just define a default key in case the user did
  // not put one, for example if single child
  return element.key ?? "defaultKey";
}
Enter fullscreen mode Exit fullscreen mode

Alright, now let's get keys of the current render and of the previous one to determine which elements have been removed:

import { useRef, useLayoutEffect } from "react";

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter((key) => !currentKeys.includes(key))
  );
}
Enter fullscreen mode Exit fullscreen mode

Get removed elements

Now that we get keys of element that will unmount in the current render, we need to get the matching element.

To do that the easier way is to make a map of elements by key.

function getElementByKeyMap(validChildren, map) {
  return validChildren.reduce((acc, child) => {
    const key = getKey(child);
    acc[key] = child;
    return acc;
  }, map);
}
Enter fullscreen mode Exit fullscreen mode

And we keep the value in a ref to preserve values at each render:

import { useRef, useLayoutEffect } from "react";

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);
  const elementByKey = useRef(
    getElementByKeyMap(validChildren, {})
  );

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  useLayoutEffect(() => {
    elementByKey.current = getElementByKeyMap(
      validChildren,
      elementByKey.current
    );
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter((key) => !currentKeys.includes(key))
  );

  // And now we can get removed elements from elementByKey
}
Enter fullscreen mode Exit fullscreen mode

It's going well!


What's going next?

As we have seen at the beginning we can't do the exit animation when unmounting the component thanks to the cleaning function in useEffect.
So we will launch this animation thanks to a boolean isVisible that will trigger

  • the enter animation if true
  • the exit one if false.

This property will be injected to the AnimatedComponent by AnimatePresence thanks to the React.cloneElement API.

So we are going to change dynamically at each render the element that are displayed:

  • inject isVisible={true} if always presents
  • inject isVisible={false} if removed

Note: We are going to introduce a new variable named childrenToRender to do that.


Injection of isVisible into AnimatedComponent

import { useRef, useLayoutEffect } from "react";

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);
  const elementByKey = useRef(
    getElementByKeyMap(validChildren, {})
  );

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  useLayoutEffect(() => {
    elementByKey.current = getElementByKeyMap(
      validChildren,
      elementByKey.current
    );
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter((key) => !currentKeys.includes(key))
  );

  // We know that `validChildren` are visible
  const childrenToRender = validChildren.map((child) =>
    React.cloneElement(child, { isVisible: true })
  );

  // We loop through removed children to add them with
  // `isVisible` to false
  removedChildrenKey.forEach((removedKey) => {
    // We get the element thanks to the object
    // previously builded
    const element = elementByKey.current[removedKey];
    // We get the index of the element to add it
    // at the right position
    const elementIndex = previousKeys.indexOf(removedKey);

    // Add the element to the rendered children
    childrenToRender.splice(
      elementIndex,
      0,
      React.cloneElement(element, { isVisible: false })
    );
  });

  // We don't return `children` but the processed children
  return childrenToRender;
}
Enter fullscreen mode Exit fullscreen mode

Oh wouah!
The animation works now but it's not totally perfect because the element stays in the tree. We need to re-render the AnimatePresence when all exit animation has been done.

We can know when an animation is ended thanks to the animation.finished promise.


useForceRender hook

The useForceRender hook can be done with a simple counter:

import { useState, useCallback } from "react";

function useForceRender() {
  const [_, setCount] = useState(0);

  return useCallback(
    () => setCount((prev) => prev + 1),
    []
  );
}
Enter fullscreen mode Exit fullscreen mode

Re-render when all exit animation are done

The final step is to re-render the AnimatePresence component when all the exit animation are finished to render the right React elements.

After this triggered render, there will be no more the removed element in the React tree.

import { useRef, useLayoutEffect } from "react";

function AnimatePresence({ children }) {
  const forceRender = useForceRender();
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);
  const elementByKey = useRef(
    getElementByKeyMap(validChildren, {})
  );

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  useLayoutEffect(() => {
    elementByKey.current = getElementByKeyMap(
      validChildren,
      elementByKey.current
    );
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter((key) => !currentKeys.includes(key))
  );

  const childrenToRender = validChildren.map((child) =>
    React.cloneElement(child, { isVisible: true })
  );

  removedChildrenKey.forEach((removedKey) => {
    const element = elementByKey.current[removedKey];
    const elementIndex = previousKeys.indexOf(removedKey);

    const onExitAnimationDone = () => {
      removedChildrenKey.delete(removedKey);

      if (!removedChildrenKey.size) {
        forceRender();
      }
    };

    childrenToRender.splice(
      elementIndex,
      0,
      React.cloneElement(element, {
        isVisible: false,
        onExitAnimationDone,
      })
    );
  });

  return childrenToRender;
}
Enter fullscreen mode Exit fullscreen mode

And the AnimateComponent finally becomes:

const AnimatedComponent =
  (Tag) =>
  ({
    onExit,
    onEnter,
    isVisible,
    onExitAnimationDone,
    ...otherProps
  }) => {
    const elementRef = useRef(null);

    useEffect(() => {
      if (isVisible) {
        const animation = elementRef.current.animate(
          onEnter,
          {
            duration: 2000,
            fill: "forwards",
          }
        );

        return () => animation.cancel();
      } else {
        const animation = elementRef.current.animate(
          onExit,
          {
            duration: 2000,
            fill: "forwards",
          }
        );
        animation.commitStyles();
        // When the animation has ended
        // we call `onExitAnimationDone`
        animation.finished.then(onExitAnimationDone);

        return () => animation.cancel();
      }
    }, [isVisible]);

    return <Tag {...otherProps} ref={elementRef} />;
  };
Enter fullscreen mode Exit fullscreen mode

And here we go!


Conclusion

I hope I've managed to make you understand how it all works under the hood.
Actually the real implementation is not the same that I have done. They do not cloneElement but use the React context API to be able not to pass directly an animated component (motion.something).
But the main point to remember is the usage of references to get children of previous render and that the returned JSX is something processed by the AnimatePresence that manages the animation of its children and more specifically the exit one by delaying the unmount of components to see the animation.

If you have any question do not hesitate to ask me.


Do not hesitate to comment and if you want to see more, you can follow me on Twitch or go to my Website.

Discussion (0)