How to animate a TanStack Virtual list with Motion

I love Motion. It makes animating UIs in web apps incredibly easy. I've been using it in my app to animate components like the tags input bar, the notebook list, dialogs, and more.
One of the components I wanted to animate was the note list. This was challenging because the list can be long, and its items have variable heights depending on the content.
Recently, I migrated the note list from react-list to TanStack Virtual because it seemed more versatile and performant for recent React versions. However, I couldn't find any concrete guides on animating the insertion and removal of list items, so I decided to tackle it myself.
In this article, I'll share a tip on how to animate list item insertion and removal using TanStack Virtual and Motion.
Demo
First things first, check out a quick demo here. I've forked the official dynamic example and modified a few lines.
A quick demo animating removal of one of the list items
In this demo, the second list item is removed two seconds later with an animation. The list contains 10,000 items but shows no performance regression.
Use AnimatePresence
for each item
The technique is simple: wrap each item with AnimatePresence
like this:
{items.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={
virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'
}
>
<AnimatePresence initial={false}>
{removed && virtualRow.index === 1 ? null : (
<motion.div
style={{ padding: '10px 0' }}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<div>Row {virtualRow.index}</div>
<div>{sentences[virtualRow.index]}</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
The removed
state is updated in a useEffect
hook for the demo:
const [removed, setRemoved] = React.useState(false);
React.useEffect(() => {
setTimeout(() => {
setRemoved(true);
}, 2000);
}, []);
With this approach, you need to know beforehand which items have been added or removed, depending on your app's implementation.
Do not wrap the entire list with a single AnimatePresence
!
Why should you wrap list items individually, instead of wrapping the whole list? That was my initial approach, but it broke the list, causing issues like this:

The issue was that items weren't properly unmounted due to the use of the layout
prop in motion.div
, as I demonstrated in the vlog:
TanStack Virtual optimizes rendering performance by only rendering items that are in the viewport, unmounting items that scroll out. This behavior conflicts with AnimatePresence
, which watches for changes in its child elements, leading to unintended animations.
By wrapping each item individually, you avoid these issues and ensure smooth animations when inserting or removing items.
That's it! I can't wait to apply this technique to my app 😄
