How to animate a TanStack Virtual list with motion (rev. 2)

The desktop version of my note-taking app is built with React and Electron.
Previously, I managed to animate the insertion and removal of specific items of a TanStack Virtual list using motion.
However, that approach would always trigger animations whenever the note list content changed, including when switching notebooks from the sidebar. Ideally, the animation should be triggered only when the data itself changes.
I've finally got it to work, as demonstrated below:
From vlog: https://youtu.be/9J9cL_VyLRY
This feature is now available in Inkdrop Desktop v5.11.2! 🥳
I'd like to share how I achieved this in this article.
Conditionally enable animations

In React Native, there is an API called configureNext()
, which schedules an animation to happen on the next layout.
I guess my app could take a similar approach to conditionally trigger animations in the note list. So, the rendering flow would looks something like this:

Re-rendering the entire list doesn't cause performance regression
With this approach, list items are rendered as regular div
elements instead of motion.div
and the list container don't wrap them with AnimatePresence
when the database doesn't have any new changes.
When scheduling an animation, it re-renders the whole list and items to invoke motion's animations.
Well, doesn't it affect performance? – Why TanStack Virtual is performant is that it only renders visible list items in the current viewport and scroll position.
In my app, it would be typically around 5-10 note list items at a time.
So, I assumed that it would be fine.
Switch between div and motion.div
In your note list item component, you can switch the component like so:
const Div = animationEnabled ? motion.div : 'div'
Wrap list content with AnimatePresence
In your list component, you can conditionally wrap/unwrap the content with AnimatePresence
depending on the animation flag like so:
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => listBarRef.current,
estimateSize: () => 87,
getItemKey: index => items[index]._id,
paddingStart: 0,
paddingEnd: 0,
scrollMargin: 0
})
const virtualItems = rowVirtualizer.getVirtualItems()
const listContent = virtualItems.map(virtualItem => {
const { key, index } = virtualItem
const note = items[index]
const isActive =
selectedNoteIds.indexOf(note._id) >= 0 || editingNoteId === note._id
return (
<NoteListBarItem
key={key}
index={index}
animationEnabled={animationScheduled}
ref={rowVirtualizer.measureElement}
/>
)
})
return (
<div ref={listBarRef}>
<div
className="note-list-holder"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
willChange: 'transform'
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
}}
>
{animationScheduled ? (
<AnimatePresence mode="popLayout" initial={false}>
{listContent}
</AnimatePresence>
) : (
listContent
)}
</div>
</div>
</div>
)
Schedule an animation on data changes
This depends on your app stack and implementation. In my case, I added a useEffect
hook to watch database change events and schedule an animation via Redux dispatch:
useEffect(() => {
const disableAnimationDebounced = debounce(() => {
dispatch(actions.noteListBar.disableAnimation())
}, 600)
const disposable = main.dataStore.local?.onNoteChange(() => {
dispatch(actions.noteListBar.scheduleAnimation())
disableAnimationDebounced()
})
return () => {
disposable?.dispose()
disableAnimationDebounced.flush()
}
}, [main.dataStore.local])
After finishing the animation, the flag gets disabled again.
That's it! I hope it's helpful to add a smooth list layout animation to your app 😃
