Alvaro Aguirre

How to do Exit Animations on React

Adding animations when a react component unmounts. Without any animation library. Example with a Modal component. My take and other alternatives.

You can found the code on CodeSandbox

Introduction

Throughout my career, in the projects I've worked on, giving animation to the unmounting of a component is not something that is given much importance. Even some component libraries don't do it.

Some people think that exit animations are too much and that components should simply disappear. Maybe it's because implementing an exit animation is not as simple as giving it an entrance animation. In my opinion, a component with an enter animation should have an exit animation. Otherwise, it feels broken.

In this article, I will be explaining how to do it in a Modal component, but it can be used for whatever is needed, for example, components like Popovers, Tooltips, etc. First, I will go with a simple example, and then with a more complex, comprehensive, and reusable one. I will be using styled components for the styles, but you can use any other styling library you prefer.


Without animation

First, we will go with simple example with no animations. Just a state isOpen which determines if the Modal is mounted or not.

app.tsx
const App = () => {
  const [isOpen, setIsOpen] = React.useState(false);
 
  const onOpen = () => setIsOpen(true);
  const onClose = () => setIsOpen(false);
 
  return (
    <div>
      <button onClick={onOpen}>Open Modal</button>
      {isOpen && <Modal onClose={onClose}>Modal content</Modal>}
    </div>
  );
};
modal.tsx
type ModalProps = {
  children: React.ReactNode,
  onClose: () => void,
};
 
const Modal = ({ children, onClose }: ModalProps) => {
  return (
    <Backdrop>
      <Content>
        <CloseButton onClick={onClose}>X</CloseButton>
        {children}
      </Content>
    </Backdrop>
  );
};
styles.ts
const Backdrop = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  overflow-y: auto;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
`;
 
const CloseButton = styled.button`
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
`;
 
const Content = styled.div`
  position: relative;
  width: 20rem;
  min-height: 7rem;
  max-height: 80vh;
  background-color: white;
  border-radius: 0.5rem;
  box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  display: flex;
  flex-direction: column;
  margin: 0 1rem;
`;

Adding enter animation

styles.ts
const fadeIn = keyframes`
  from { opacity: 0; transform: scale(0.9); }
  to { opacity: 1; transform: scale(1);}
`;
 
const Content = styled.div`
  ...
  animation: ${fadeIn} 150ms ease-in;
`;

Adding exit animation

We will need a state isClosing that refers to when the user closed the modal. This will be accompanied by a setTimeout, where, after a certain amount of time, we will set isOpen to false.

app.tsx
const timeoutId = React.useRef < number > 0;
 
const onClose = () => {
  setIsClosing(true);
  timeoutId.current = window.setTimeout(() => {
    setIsOpen(false);
    setIsClosing(false);
  }, 150);
};
 
React.useEffect(() => {
  return () => {
    clearTimeout(timeoutId.current);
  };
}, []);

Then, we will pass isClosing to the Modal component.

app.tsx
  isOpen && (
    <Modal onClose={onClose} $isClosing={isClosing}>
      Modal content
    </Modal>
  );
modal.tsx
const Content =styled.div <{ $isClosing: boolean }>`
  ...
  animation: ${fadeIn} 150ms ease-in;
  ${({ $isClosing }) => {
    if ($isClosing)
      return css`
        animation: ${fadeOut} 150ms ease-in forwards;
      `;
  }};
`;

Increasing the readability

If we want the code to be more readable and easier to understand, we can do the following:

Pass isOpen to Modal

app.tsx
<Modal onClose={onClose} isOpen={isOpen} isClosing={isClosing}>
  Modal content
</Modal>

Pass the same prop to Content styled component

modal.tsx
<Content $isOpen={isOpen} $isClosing={isClosing}>
  <CloseButton onClick={onClose}>X</CloseButton>
  {children}
</Content>

And handle the animations

modal.tsx
const Content = styled.div<{ $isOpen: boolean; $isClosing: boolean }>`
  ...
  animation-duration: 150ms;
  animation-timing-function: ease-in;
  animation-fill-mode: forwards;
  animation-name: ${({ $isOpen, $isClosing }) => {
    if ($isOpen && !$isClosing) return fadeIn;
    if ($isClosing) return fadeOut;
  }};
`;

Creating a custom hook useDisclosure

Probably, we will need this same logic for future components. In that case, it would be a good practice to create a custom hook so that we can reuse it. Let's create a custom hook called useDisclosure.

type UseDisclosureReturnType = {
  isOpen: boolean,
  isClosing: boolean,
  onOpen: () => void,
  onClose: () => void,
};
 
const useDisclosure = (timeout = 150): UseDisclosureReturnType => {
  const [isOpen, setIsOpen] = React.useState(false);
  const [isClosing, setIsClosing] = React.useState(false);
 
  const onOpen = () => setIsOpen(true);
 
  const timeoutId = React.useRef(0);
 
  const onClose = () => {
    setIsClosing(true);
    timeoutId.current = window.setTimeout(() => {
      setIsOpen(false);
      setIsClosing(false);
    }, timeout);
  };
 
  React.useEffect(() => {
    return () => {
      clearTimeout(timeoutId.current);
    };
  }, []);
 
  return { isOpen, isClosing, onOpen, onClose };
};

and consumit it on App:

app.tsx
const { isOpen, isClosing, onOpen, onClose } = useDisclosure();
...

Extending useDisclosure

For a more comprehensive and reusable solution, we can extend the useDisclosure hook so that we have different transition stages:

  1. unmounted
  2. mounting
  3. mounted
  4. unmounting

use-disclosure.ts
type PhasesType = "unmounted" | "mounting" | "mounted" | "unmounting";
 
type UseDisclosureReturn = {
  isOpen: boolean,
  onOpen: () => void,
  onClose: () => void,
  onToggle: () => void,
  isUnmounting: boolean,
  phase: PhasesType,
};
 
export const useDisclosure = (timeout = 150): UseDisclosureReturn => {
  const [phase, setPhase] = React.useState<PhasesType>("unmounted");
  const timeoutId = React.useRef<number>0;
 
  const onOpen = React.useCallback(() => {
    if (phase !== "unmounted") return;
    setPhase("mounting");
  }, [phase]);
 
  const onClose = React.useCallback(() => {
    if (phase !== "mounted") return;
    setPhase("unmounting");
  }, [phase]);
 
  const onToggle = React.useCallback(() => {
    if (phase === "mounting" || phase === "mounted") onClose();
    if (phase === "unmounting" || phase === "unmounted") onOpen();
  }, [onClose, onOpen, phase]);
 
  React.useEffect(() => {
    if (phase === "unmounting") {
      timeoutId.current = window.setTimeout(() => setPhase("unmounted"), timeout);
    } else if (phase === "mounting") {
      timeoutId.current = window.setTimeout(() => setPhase("mounted"), timeout);
    }
 
    return () => {
      clearTimeout(timeoutId.current);
    };
  }, [phase, timeout]);
 
  const isOpen = phase !== "unmounted";
  const isUnmounting = phase === "unmounting";
 
  return { isOpen, onOpen, onClose, onToggle, isUnmounting, phase };
};

Note that I renamed isClosing to isUnmounting. I think it's more appropriate.

Something that is worth to mention, we used 150ms for the transition between each phase for useDisclosure hook, and we also used the same value for the animation-time for the animations inside Modal. Ideally we save this value inside a variable to make sure it is the same in all the places.


Other alernatives

Without unmounting the component

Note that we are doing

app.tsx
{isOpen && (
  <Modal ...>
    Modal content
  </Modal>
)}

Another alternative I have seen is to never unmount the component by doing

app.tsx
<Modal ...>
  Modal content
</Modal>

and depending on the isOpen property, we add the styles (using pointer-events: none)

styles.tsx
const Content = styled.div`
  ...
  transition: all 150ms;
  ${({ $isOpen }) =>
    $isOpen
      ? css`
          pointer-events: auto;
          opacity: 1;
          transform: scale(1);
        `
      : css`
          pointer-events: none;
          opacity: 0;
          transform: scale(0.9);
        `};
`
 

Personally, I think this is not a good approach in terms of efficiency. We should only have mounted in the component tree what we are currently viewing.


Using onAnimationEnd

When a CSS animation is applied to an element and that animation finishes, the onAnimationEnd event is triggered. We can use this event to execute onClose.

Again, I don't think it's the best way to do an exit animation. If we remove the animation in the future, the modal won't close.