Resizable panel
Resizable panel

Watch everything for $29/month.

Join Build UI Pro

Resizable panel

Use measured height to resize a panel with dynamic content.

Video resources

Summary

Let's begin our panel's animation by fading in and out the dynamic content.

We'll start by fading out the form when it gets unmounted. Let's turn the div around the form into a motion.div and add an exit animation that sets opacity to 0. We also need to wrap the conditional in AnimatePresence for our exit animation to work, as we saw in Lesson 2.

<AnimatePresence>
  {status === "idle" || status === "saving" ? (
    <motion.div exit={{ opacity: 0 }}>
      <Form>{/* ... */}</Form>
    </motion.div>
  ) : (
    <p>{/* ... */}</p>
  )}
</AnimatePresence>

If we give this a try, we'll see it doesn't work. There's a note near the top of the docs on Exit animations that provides the answer: direct children of AnimatePresence must always have a key prop on them.

In Lesson 2, we happened to already be using a key on the elements we added exit animations too, since they were coming from an array. This is the first time we're seeing an exit animation on a single element, which typically we would not key in React. But Framer Motion relies on this for stable identity.

So, whenever we add an exit animation to a single element, we just need to remember to give it a static key. We'll use the string "form" here:

<AnimatePresence>
  {status === "idle" || status === "saving" ? (
    <motion.div exit={{ opacity: 0 }} key="form">
      <Form>{/* ... */}</Form>
    </motion.div>
  ) : (
    <p>{/* ... */}</p>
  )}
</AnimatePresence>

Now we can see the our exit animation taking effect! Although it looks a little off.

The mode prop

If we slow our animation down, we can see what's going on: right after the form saves, the new child is immediately appended to the DOM, and then our exit animation begins running. So both trees of the conditional are rendered to the screen at the same time.

This is not the behavior we want. It would be nice if we could tell Framer Motion to wait until the exit animation completes before mounting the new child.

It turns out there's a prop on AnimatePresence called mode that does just that! If we look at the docs for mode, we see this:

Determines how to handle entering and exiting elements.

  • "sync": Default. Elements animate in and out as soon as they're added/removed.
  • "popLayout": Exiting elements are "popped" from the page layout, allowing sibling elements to immediately occupy their new layouts.
  • "wait": Only renders one component at a time. Wait for the exiting component to animate out before animating the next component in.

So mode determines how to handle entering and exiting elements, and we can see the default is sync, which runs both mount and unmount animations simultaneously. And that's what's happening here: as soon as we rerender, the message is being added, and at the same time the form is unmounted and runs its exit animation.

Looking at the other values, we can see popLayout, which we'll come back to later, and wait – which sounds exactly like what we want. Let's try it out:

<AnimatePresence mode="wait">
  {/* ... */}
</AnimatePresence>

Now our form fades out, and when it's done our message appears!

So, mode is how we control the orchestration of entering and exiting elements. It takes effect whenever a single rerender triggers elements being both mounted and unmounted from the component tree.

Fading in the message

Right now our success message appears immediately on mount. Let's add a mount animation to fade it in:

<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
  <p className="p-8 text-sm text-zinc-400">
    Email sent! Check your inbox to continue.
  </p>
</motion.div>

It works! And if we look at this in full speed, you probably see this on a lot of sites. The old content fades out, the new content fades, but the height of surrounding content doesn't animate. And it really sticks out, since we've added animation to part of our interface, but neglected it in others.

We want to avoid this kind of uncanny valley because it creates an unpolished look. So next, let's work on animating the height of our dynamic content.

Animating the form and message's height

First, let's try animating the height of the form to 0 as it unmounts. As we saw in Lesson 2, Framer Motion is capable of animating an element's height from auto to 0, and since height is auto by default, we can just add 0 to our exit animation:

<motion.div
  exit={{
    opacity: 0,
    height: 0
  }}
  key="form"
>
  {/* ... */}
</motion.div>

If we slow it down, we can see our form now shrinks on exit.

There's an issue with overflow, which is happening because Framer Motion uses absolute positioning for its exit animations. We can fix this by adding overflow-hidden to the panel's root div.

Next, lets add a height animation to the message from 0 to auto on mount:

<motion.div
  initial={{
    opacity: 0,
    height: 0
  }}
  animate={{
    opacity: 1,
    height: "auto"
  }}
/>

The message now grows when it enters the DOM.

If we look at it in full speed, we have a strange accordion-like behavior. The form fully shrinks to 0, then the message grows from 0, so the panel gets too small.

This isn't quite what we want, and it's happening because we're using wait for the mode. Let's go back to sync, which will run both mount and unmount animations at the same time.

<AnimatePresence mode="sync">
  {status === "idle" || status === "saving" ? (
    <motion.div exit={{ opacity: 0, height: 0 }} key="form">
      {/* ... */}
    </motion.div>
  ) : (
    <motion.div
      initial={{ opacity: 0, height: 0 }}
      animate={{ opacity: 1, height: "auto" }}
    >
      <p>{/* ... */}</p>
    </motion.div>
  )}
</AnimatePresence>

Closer... we can see the form and message are both animating height, and the overall panel does smoothly resize from its old height to its new height without shrinking past the final value. But there's some strange movement happening with the children.

If we look at the inspector, we can see how both elements' simultaneous height animations are causing this strange shift.

Instead of continuing down this path of animating both elements' height, we should take a step back and consider our approach. Fundamentally, it's just not aligned with what we're trying to do here. If look at the overall animation at full speed, conceptually we have two different animations ocurring: first, the content of the panel is fading out and in; and second, simultaneously, the panel itself is animating its height.

It would be much simpler if we could actually implement these two effects as two separate animations that happen in parallel.

So, let's remove the height animation from each element, and instead animate the panel's height directly.

Animating the panel's height

Now that we've removed the height from the form and message, let's take a look at the animation again. The fades are overlapping each other, which is not what we want.

Let's change our mode back from wait to sync:

<AnimatePresence mode="sync">
  {/* ... */}
</AnimatePresence>

Looks better!

Now, let's work on animating the panel's height. We only need to worry about the dynamic contents within the AnimatePresence, so let's create a new div that wraps it, turn it into a motion.div, and add a height animation:

<motion.div animate={{ height: 100 }}>
  <AnimatePresence mode="sync">
    {/* ... */}
  </AnimatePresence>
</motion.div>

As we saw in Lesson 2, if we are just using the animate prop to animate height, we can pass different values in here. Framer Motion can smoothly animate between values like 100 and 200, and it can also animate from 0 to auto and auto to 0.

In our case, conceptually what we want is auto to auto, since we want the height of this div to be based on its contents. Ideally, we could leave height as auto, and it would animate between the height of the old and new content:

<motion.div animate={{ height: 'auto' }}>
  <AnimatePresence mode="sync">
    {/* ... */}
  </AnimatePresence>
</motion.div>

If we try this, we'll see that no animation is triggered. Unfortunately this is not supported out of the box. Framer Motion can animate from 0 to auto for a reveal animation, and from auto to 0 for a collapse animation, but it can't animate from auto to auto for a resize animation, which is the effect we're trying to pull off here.

What other approach could we take? If we knew the absolute pixel values of the before and after states, we could use them to directly animate the panel.

Using the inspector, we can see the form is 204 pixels tall, and the message is 84. So let's use these numbers and the logic from our template to conditionally animate the height:

<motion.div
  animate={{
    height: status === "idle" || status === "saving" ? 204 : 84,
  }}
>
  <AnimatePresence mode="sync">
    {status === "idle" || status === "saving" ? (
      <motion.div exit={{ opacity: 0 }} key="form">
        <Form>{/* ... */}</Form>
      </motion.div>
    ) : (
      <p>{/* ... */}</p>
    )}
  </AnimatePresence>
</motion.div>

Look at that – our panel is now smoothly resizing!

We've introduced a new orchestration issue. The panel finishes its resize animation when the form fades out, leaving the message to fade in on its own. Ideally we'd like the panel resize to span both fades. This is happening because there are three animations happening that all share the same duration of 1 second: the form fades out in 1 second, then the message fades in in 1 second; and in parallel, the panel resizes in 1 second.

Let's change the duration of the fades to 0.5 seconds so that they take a total of 1 second to match our panel:

<motion.div
  key="form"
  exit={{ opacity: 0 }}
  transition={{ duration: 0.5 }}
>
  {/* ... */}
</motion.div>

<motion.div
  initial={{ opacity: 0, height: 0 }}
  animate={{ opacity: 1, height: "auto" }}
  transition={{ duration: 0.5 }}
>
  {/* ... */}
</motion.div>

Now everything works!

Paramterizing the transition

Since we now have a relationship between our duration of 1 second (defined in our root MotionConfig) and these 0.5 second durations, let's encode this in a variable so it's easy to change.

The best way to do this is to use a shared object:

let transition = { type: "ease", ease: "easeInOut", duration: 0.4 };

We can pass this into our root MotionConfig

<MotionConfig transition={transition}>
  {/* ... */}
</MotionConfig>

...and then use the splat operator to override individual properties on our fading elements:

<motion.div
  key="form"
  exit={{ opacity: 0 }}
  transition={{
    ...transition,
    duration: transition.duration / 2
  }}
>
  {/* ... */}
</motion.div>

<motion.div
  initial={{ opacity: 0, height: 0 }}
  animate={{ opacity: 1, height: "auto" }}
  transition={{
    ...transition,
    duration: transition.duration / 2
  }}
>
  {/* ... */}
</motion.div>

Just remember to splat the variable first so your overrides take effect!

Removing magic numbers with useMeasure

We're getting close! Our animation is working great, but hopefully you noticed a bit of a code smell: we're relying on hard-coded numbers of 204 and 84 in our calculation.

If we were to change the contents of either the form or the message, our animation would break.

How might we measure these numbers programatically? There's several libraries that do this, but the one we're using today is called react-use-measure which provides us with a useMeasure hook.

We can use the hook directly in our component:

let [ref, bounds] = useMeasure();

The hook returns a ref and a bounds object. We can set the ref on any element, and the bounds will stay updated with its latest measurements, including its height.

Let's wrap our dynamic content with a new div and pass it this ref:

function Page() {
  let [ref, bounds] = useMeasure();

  console.log(bounds.height);

  return (
    <motion.div
      animate={{
        height: status === "idle" || status === "saving" ? 204 : 84,
      }}
    >
      <div ref={ref}>
        <AnimatePresence mode="sync">{/* ... */}</AnimatePresence>
      </div>
    </motion.div>
  );
}

If we check the console, we'll see 204 and 84 logged! We can now remove the magic numbers and the conditional logic and simply use bounds.height for our animation:

function Page() {
  let [ref, bounds] = useMeasure();

  return (
    <motion.div
      animate={{
        height: bounds.height,
      }}
    >
      <div ref={ref}>
        <AnimatePresence mode="sync">{/* ... */}</AnimatePresence>
      </div>
    </motion.div>
  );
}

If we refresh and try out our animation, we'll see we have our height animation back. No more magic numbers!

Using popLayout

Our panel is animating, but we have another orchestration issue. If we slow it down, we'll see that the panel doesn't start animating until the form completely finishes its unmount animation.

This is because we're using wait for our mode. So, we don't give useMeasure a chance to measure the final height until after the form has faded out.

If we change the mode back to sync, we see some new behavior. The panel animates from 204, to 288, and then down to 84. This is because sync runs both unmount and mount animations simultaneously, so both the form and message are in the DOM at the same time. 288 is the sum of 204 and 84, which is why the panel grows before it shrinks. So sync is not going to work for us either.

And this is where popLayout comes in, which is the third and final option for mode.

<AnimatePresence mode="popLayout">
  {/* ... */}
</AnimatePresence>

Recall the description for popLayout:

  • "popLayout": Exiting elements are "popped" from the page layout, allowing sibling elements to immediately occupy their new layouts.

This is exactly what we want. Framer Motion will use absolute positioning to immediately remove the unmounting component while it runs its animation, while also immediately mounting new entering elements to the DOM. This lets us measure the final height of the panel right at the beginning of the animation, which is exactly what we want. And we can see that it works!

Now, we see another orchestration issue. Because popLayout triggers the exit and enter animations at the same time, we're seeing kind of cross-fading behavior happening with our dynamic content. They fade in and out on top of each other, since they both run at the same time, and because popLayout places them on top of each other.

We can fix that by adding a delay to our message so it doesn't run its mount animation until after the form has finished fading out:

<motion.div
  initial={{ opacity: 0 }}
  animate={{ opacity: 1 }}
  transition={{
    ...transition,
    duration: transition.duration / 2,
    delay: transition.duration / 2,
  }}
/>

And now we're back in business.

So, popLayout is the perfect mode for what we're doing here. It pops out the exiting element, giving useMeasure an opportunity to measure the ending height of the panel so that it can kick off its height animation immediately.

One final small bug fix. If we look closely, we can see the panel moving a bit on initial render. This is because useMeasure returns 0 for all of its bounds values on the first render. It needs a frame in order for it to measure the initial bounds of the ref we've given it. This is really just an implementation detail of react-use-measure, but one we have to be aware of.

So, if the bounds.height is ever 0, it's really more like undefined. We can update our height code to account for this:

<motion.div
  animate={{ height: bounds.height > 0 ? bounds.height : null }}
/>

Passing null effectively disables the height animation, and we've removed the bounce on initial render.

Using a spring for the panel's animation

Our current transition works well for the fading content, but feels a bit lifeless when the panel animates its height. Let's try tweaking its transition values.

We'll start by using a type of spring:

<motion.div
  animate={{ height: bounds.height > 0 ? bounds.height : null }}
  transition={{ type: "spring" }}
/>

Fun! But a bit too bouncy. Let's customize it using the bounce prop, which takes a number between 0 and 1:

<motion.div
  animate={{ height: bounds.height > 0 ? bounds.height : null }}
  transition={{ type: "spring", bounce: 0.2 }}
/>
<motion.div
  animate={{ height: bounds.height > 0 ? bounds.height : null }}
  transition={{ type: "spring", bounce: 0.2, duration: 0.8 }}
/>

0.8 works nicely.

By specifying the duration here, we've lost the precise coordination between the bounce and the fades. But once multiple animating elements are using different types and easings, this is mostly unavoidable. Your eye and judgement are your best tools here, so just tinker until things feel right.

There you have it! A polished, smooth resizable panel perfect for forms, signups, and anything else that needs to resize while reflowing the surrounding contents.

Join Build UI Pro to access this video's summary and source code.

Join Build UI Pro

Watch every video, support our work, and get exclusive perks!

Build UI is the new home for all our ideas. It will eventually have hundreds of premium videos and a thriving community, but right now it's the early days.

If you like what you see and you've ever wanted to support our work, subscribe today and start enjoying all the perks of becoming a member!

$29/month

Watch everything. Cancel anytime.

What you'll get as a Build UI Pro member

Full access to all Build UI videos

Get full access to all of our premium video content, updated monthly.

Private Discord

Ask questions and get answers from Sam, Ryan and other pro members.

Video summaries with code snippets

Easily reference videos with text summaries and copyable code snippets.

Source code

View the source code for every video, right on GitHub.

Invoices and receipts

Get reimbursed from your employer for becoming a better coder!