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.
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>
);
};
type ModalProps = {
children: React.ReactNode,
onClose: () => void,
};
const Modal = ({ children, onClose }: ModalProps) => {
return (
<Backdrop>
<Content>
<CloseButton onClick={onClose}>X</CloseButton>
{children}
</Content>
</Backdrop>
);
};
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
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.
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.
isOpen && (
<Modal onClose={onClose} $isClosing={isClosing}>
Modal content
</Modal>
);
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
<Modal onClose={onClose} isOpen={isOpen} isClosing={isClosing}>
Modal content
</Modal>
Pass the same prop to Content styled component
<Content $isOpen={isOpen} $isClosing={isClosing}>
<CloseButton onClick={onClose}>X</CloseButton>
{children}
</Content>
And handle the animations
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:
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:
unmounted
mounting
mounted
unmounting
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
{isOpen && (
<Modal ...>
Modal content
</Modal>
)}
Another alternative I have seen is to never unmount the component by doing
<Modal ...>
Modal content
</Modal>
and depending on the isOpen
property, we add the styles (using pointer-events: none
)
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.