It feels like whenever the topic of animations (whether that is web or otherwise) comes up, the Family App is front and center of all the examples. And for good reason.
It represents incredible attention to detail and a deep understanding of animation principles. And for this very reason, when I originally started learning motion design, I had set aspects of this app as my goal to recreate.
Naturally, the app is filled with lots of great interactions, but here, I will focus on just one of them, a wallet options drawer:
The most important aspect of this whole component is the motion, no doubt about that, so with my first attempt, I ignored the icons, colours and typography and ended up with this:
Now, let's break down each part of this initial
import { AnimatePresence, motion } from "motion/react"
import { useMemo, useState } from "react"
import useMeasure from "react-use-measure"
motion/react is the library that provides the motion primitives, like motion.div which allows us to animate pretty much every property of that element.
In the code above, we define the initial, animate, and exit properties of the element in the drawer. The initial property is the state that the element will be in when it is first added to the
AnimatePresence is crucial here since it allows components to animate out before being removed from the DOM, rather than disappearing instantly. Without it, the exit animation would never play.
react-use-measure provides the useMeasure
const [view, setView] = useState(0);
const [elementRef, bounds] = useMeasure();
The view
The options array contains the different
Moving onto the most important part of the component, and animated container which holds the views and decides how to animate between them:
<motion.div
animate={{ height: bounds.height }}
transition={{
type: "tween",
ease: [0.26, 1, 0.5, 1],
bounce: 0,
duration: 0.27,
}}
className="drawer"
>
<div className="drawer-content" ref={elementRef}>
<AnimatePresence initial={false} mode="popLayout" custom={view}>
<motion.div
initial={{ opacity: 0, scale: 0.96, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0)" }}
exit={{
opacity: 0,
scale: 0.96,
filter: "blur(2px)",
transition: {
opacity: { duration: 0.19, ease: [0.26, 0.08, 0.25, 1] },
default: { duration: 0.27, ease: [0.26, 0.08, 0.25, 1] },
},
}}
key={view}
transition={{
duration: 0.27,
ease: [0.26, 0.08, 0.25, 1],
}}
>
{content}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
The outer motion.div handles the height animation. It animates to bounds.height, the measured height of the content inside, creating smooth expansion and contraction as views change. The transition there is set to use a custom cubic-bezier
The inner div with ref={elementRef} is what gets measured by useMeasure. This creates the feedback loop: content changes → new height measured → outer container animates to new height (using the easing curve mentioned above).
AnimatePresence wraps the content with mode="popLayout", so that when a view is exited, it is "popped" out of the DOM, so it does not cause a layout shift by interacting with the new view which is animating in. The custom={view} prop passes the current
The inner motion.div handles the content transitions. It starts with opacity: 0, slightly scaled down (scale: 0.96), and blurred. The animate state brings it to full opacity, normal scale, and removes the blur. The exit animation reverses this process, with separate timing for opacity (190ms) versus other properties (270ms) to have less overlap between the two views.
Good news! This is the most complex part of the component, and it is now done! The next steps for it are to replace the filler text with actual recreations of the private key and recovery phrase screens taken from the app.
Now, with this version, the only meaningful code change that was made, was adding the "open runde" font (which is an alternative to the "SF Pro Rounded" font that I presume is used in the original app) as well as the icon library lucide-react.
The options array is now populated with the actual views, which are imported from the ./InitialView, ./KeyView and ./RecoveryView files.
const options = [
<InitialView
key="initial"
onViewKey={() => setView(1)}
onViewRecovery={() => setView(2)}
onRemoveWallet={() => {}}
/>,
<KeyView key="key" ... />,
<RecoveryView key="recovery" ... />,
];
Inside every view is a
Since in the beginning, the code for the parent component included lines to automatically resize itself, no other changes need to be made to the parent, since we could place anything in the views and it would fit. And with that, the component is finished!
Thank you for reading!