If you are a frontend developer, you probably know that motion
is a powerful library for animating your components. One of its standout features is ensuring that elements with animations are not removed from the DOM until their animations are complete. However, the library comes with a size cost: 116.5k (gzipped: 37.5k)
, which can be significant for simple use cases. If you only need basic animations with AnimatePresence
, you can rebuild it without motion
.
In this blog, we will learn how to create a simple animation using (motion
and AnimatePresence
) and then replicate its functionality using native JavaScript and React.
Using motion
and AnimatePresence
import { motion, AnimatePresence } from "motion/react";
import { useState } from "react";
function Ball() {
const [visible, setVisible] = useState(true);
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 30 }}
>
⚾
</motion.div>
)}
</AnimatePresence>
);
}
With AniamtePresence
In the With AnimatePresence
case, the Show/Hide Button
does not shift until the animation is complete. This behavior occurs because AnimatePresence
compares the previous childrens
with the new children
. If it detects removed children, it triggers the "exit" animation before finally removing the elements.
Rebuilding AnimatePresence
Steps:
- Compare the previous
childrens
with the newchildren
, and activateanimate__exit
animation - Remove any children marked with
animate__exit
class after the animations are complete - Manage new children added to the DOM
Prerequisites
- What are the differences between
ReactElement
andReactNode
? - What is
React.Children
and how to use it?
1. Compare the previous childrens
with the new children
, and activate animate__exit
animation
We’ll maintain a state to track the current children and compare them with new children to detect removals. Removed elements will get the animate__exit
class.
import React from "react";
// Minimal types for cloning element
interface CloningElementProps {
className?: string;
style?: { animation?: string }
}
function AnimatePresence({ children }: { children: React.ReactNode }) {
const [childrens, setChildrens] = React.useState<ReactNode[]>(
React.Children.toArray(children)
);
useEffect(() => {
const pre = childrens.map((child) => {
const exists = React.Children.toArray(children).some((curChild) => {
return (
React.isValidElement(child) &&
React.isValidElement(curChild) &&
child.key == curChild.key
);
});
return !exists && React.isValidElement<CloningElementProps>(child)
? cloneElement(child, {
className: (child.props.className ?? "").replace(
"animate__enter",
"animate__exit"
),
})
: child;
});
setChildrens(pre);
}, [children]);
useEffect(() => {
// STEP 2
}, [childrens]);
return childrens;
}
Explanation
- The
useEffect
listens for changes inchildren
. - We compare previous
childrens
with newchildren
using thekey
property. - For removed elements, we replace
animate__enter
withanimate__exit
to trigger the exit animation.
Hold on
In my case, I designed AnimatePresence
to work with simple keyframes. As when animating out,
I'm going to remove the animate__enter
class, and add animate__exit
class.
Another interesting tip 😊! You can use this cubic bazier cubic-bezier(0.34, 1.56, 0.64, 1)
, to
make a spring like transition (like in motion
)
/* Enter */
.animate__enter {
transform: translateX(-30px);
opacity: 0;
animation: enter 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes enter {
to {
transform: translateX(0);
opacity: 1;
}
}
/* Exit */
.animate__exit {
animation: exit 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes exit {
to {
transform: translateX(30px);
opacity: 0;
}
}
2. Remove any children marked with animate__exit
class after the animations are complete
useEffect(() => {
// STEP 2
childrens.forEach((child) => {
if (!React.isValidElement < CloningElementProps > child) return;
if (!child.props.className) return;
if (child.props.className.includes("animate__exit")) {
setTimeout(() => {
setChildrens((prevChildrens) => {
return prevChildrens.filter(
(prevChild) =>
React.isValidElement(prevChild) && prevChild.key !== child.key
);
});
}, child.props.style?.transitionDuration ?? 300);
}
});
}, [childrens]);
Tip: If you wish, you could change the timeout duration using something like data-duration
attribute to ensure the removal aligns with the animation length.
3. Manage new children
useEffect(() => {
// Unchaged `pre`
const post = React.Children.toArray(children).filter((child) => {
const exists = childrens.some((curChild) => {
return (
React.isValidElement(child) &&
React.isValidElement(curChild) &&
child.key == curChild.key
);
});
return !exists;
});
setChildrens(pre);
setChildrens([...pre, ...post]);
}, [children]);
Explanation
pre
identifies removed elements by comparingchildrens
withchildren
.post
identifies new elements by comparingchildren
withchildrens
.- Then merge them.
Finally, you can use this AnimatePresence
in simple use cases instead of motion
. Let's see
our implementation:
motion
If you find areas for improvement in this approach or have suggestions, we’d love to hear your feedback! 💚