Introduction
List animation is something that can greatly improve the quality of your website. It's simple and elegant. However, implementing this seemingly simple animation is not as straightforward. As you can see in the video below, whenever a new item is added or removed, it will nicely animate from zero height to the correct height. So we're going to need to animate the height property.
Video description:
- A demo of a list being added and removed with a smooth animation.
In this article, I won't go through the basics of Motion for React (previously Framer Motion). I will assume that you know how to do basic animation and are familiar with how AnimatePresence works. I will mainly share the gotchas and hidden rules of animating lists, because frankly, there are a lot.
Let's start 👍
Enter and Exit Animation
When you want to animate a list, what you need to do is create enter and exit animations using AnimatePresence from the motion library.
The key consideration here is where to put the height animation.
Auto Height Animation
This is the most basic boilerplate that you need for a height animation with initial and exit.
<AnimatePresence initial={false}>
{counts.map((count) => (
<motion.div
key={count}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<motion.div
className={clsx([
'flex items-center justify-between',
'px-4 py-1 rounded-xl',
'bg-neutral-50 border border-gray-300',
])}
initial={{
opacity: 0,
y: -8,
scale: 0.98,
filter: 'blur(4px)',
}}
animate={{
opacity: 1,
y: 0,
scale: 1,
filter: 'blur(0px)',
}}
exit={{
opacity: 0,
y: 8,
scale: 0.98,
filter: 'blur(4px)',
}}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
<p className='text-neutral-950 text-sm'>List Item {count}</p>
<IconButton>Delete</IconButton>
</motion.div>
</motion.div>
))}
</AnimatePresence>
Let me explain the code below.
First, Here's the demo.
Video description:
- When adding an item, a list item appears and the bottom element moves smoothly without any flicker
- The same goes with deleting; there's no layout shift and it animates smoothly
If you look at the code, I used 2 divs.
The outer div is responsible for the height animation, and the inner div is responsible for moving down the element to make the animation nicer.
I also play with two different transition durations so the items inside will be animated faster for better effect.
Tip: Try skimming the demo video frame by frame so you can see it move individually.
Why do we need two divs?
When you animate height, it's mandatory to have at least two divs. It can also be more than two—you'll see more of this when we're doing spacing between items.
It's because when we animate height, we need to make sure that the text or items inside don't shift.
I'll show you what happens if we only use one div for height animation. I made the duration slower so it's easier for you to see.
Video description:
- When adding an item, the div starts from the minimum height of text instead of from 0.
- When removing an item, the border also gets smaller, making it feel like the card shrinks instead of being removed beautifully.
- When deleted, it shrinks to minimum height, then is removed abruptly.
Get it? When you animate from or to 0, CSS won't allow you to shrink down because we have the text inside. It's a basic rule of CSS minimum height, thus causing a flicker.
Using an outer div for height animation and an inner div for the actual content is the first rule.
Note:
The two div solution is not absolute; there are multiple ways you can solve this. But I recommend this one based on my experience.
Adding Space between List Items
Margins and Gaps don't work on height animation.
This is because margin is not calculated inside the height. Height is calculated from: contents, padding, and borders (if border-box). So margins and gaps are not animated.
Here, let me show you what happens if we just use simple margins or gaps
Video description:
- When clicking delete, the list item slowly transitions in height
- After all height is gone, there's a flicker. This occurs because the
margin-top
is abruptly unmounted from the page - The behavior is the same whether you use margins or gaps
There are two ways we can solve this:
-
Animating the margin
<inner-div initial={{ opacity: 0, y: -8, scale: 0.98, filter: 'blur(4px)', marginTop: 0, }} animate={{ opacity: 1, y: 0, scale: 1, filter: 'blur(0px)', marginTop: 4, }} exit={{ opacity: 0, y: 8, scale: 0.98, filter: 'blur(4px)', marginTop: 0, }} >
This will work as expected since you also animate the margin value, so it won't flicker.
-
Wrapping the inner div with another div and padding
Because padding is calculated inside the height, we can utilize a wrapper padding div to simulate space.
<outer-div> <div className='py-2'> <inner-div /> </div> </outer-div>
Here's the diagram
We need an extra padding div (red) because we want to keep the border behavior on the inner div (orange).
Personally, I prefer the second method. Feel free to choose any one you like.
Extracting to a Component
Whenever you put the motion item into a component, you need to make sure that you forward the ref. Another rule is that you need to make sure it has a key.
These two rules are needed because AnimatePresence
needs a ref and key to operate correctly.
<AnimatePresence initial={false}>
{countList.map((count, i) => (
<SimpleItem
key={count}
count={count}
/>
))}
</AnimatePresence>
export const SimpleItem = React.forwardRef((props, ref) => {
return (
<motion.div ref={ref} heightAnimation...>
...
</motion.div>
})
4 Rules for Enter and Exit Animation in Lists
So in conclusion, there are 4 rules for enter and exit animations for lists:
- We need 2 divs - the outer one is for height animation, and the inner one is for the actual content
- Margins and gaps are not automatically animated with height. Either animate them explicitly, or use a wrapper padding div to simulate them.
- If using a component, make sure to forward the ref.
- Items inside AnimatePresence must have keys.
Hope this helps!
If you want to check the full code and example, you can check it out here: