Back

Recreating the Family Drawer

August 2025

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:

Private Key

Lorem ipsum dolor sit amet consectetur adipisicing elit. Voluptates dolorem fugit consequatur sint necessitatibus natus deserunt? Impedit deleniti libero necessitatibus, fuga officiis autem consectetur.

Now, let's break down each part of this initial , starting with all of those imports, and why they are needed.

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 using initial, animate, and exit props.

AnimatePresence is crucial here since it allows components to animate out before being removed from the , rather than disappearing instantly. Without it, the exit animation would never play.

react-use-measure provides the useMeasure , which tracks the dimensions of DOM elements in real-time. This is essential for creating smooth height transitions as the drawer content changes as we can animate to the actual measured height rather than guessing or using fixed values.

const [view, setView] = useState(0);
const [elementRef, bounds] = useMeasure();

The view tracks which content is currently displayed, starting at index 0. The useMeasure hook returns two things: elementRef (a to attach to the element we want to measure) and bounds (an object containing the element's dimensions, including height, width, and position).

The options array contains the different that can be displayed in the drawer, in this case, just some lorem ipsum text for each, which I will later replace with actual recreations of the private key and recovery phrase screens taken from the app. Each of the views also contains a button that cycles to the next view.

Moving onto the most important part of the component, an 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="overflow-hidden rounded-[2rem] border border-gray-200 bg-white"
>
  <div className="p-6" 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 [0.26, 1, 0.5, 1] which is a snappy but smooth curve.

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 to child animations.

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.

Options

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 that instructs the to change the view to the chosen index.

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!