How to animate a TanStack Virtual list with Motion

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:

0:00
/0:08

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 😄

Inkdrop - Note-taking App with Robust Markdown Editor
The Note-Taking App with Robust Markdown Editor