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

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:

0:00
/0:02

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

Animations · React Native
Animations are very important to create a great user experience. Stationary objects must overcome inertia as they start moving. Objects in motion have momentum and rarely come to a stop immediately. Animations allow you to convey physically believable motion in your interface.

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 😃

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

Read more