Dev tricks: Animating a reordered list in React

Smooth list item transitions without a third-party library.

Tags: Dev tricks


CSS animation is not an area I’ve invested a lot of time into learning. Most of the work I’ve done building user interfaces has involved densely populated administrative views where function dominates over form. So, I was excited to encounter a valid use case for implementing a custom CSS transition within a client project. Once I had it working, I wanted to document my discovery and share my solution in case others face a similar problem in the future.

The problem

One of the pages in my client’s web app displays a list of items fetched from a server API. Some of the operations users can perform on this page cause the items to change their order. This was quite simple to implement in React: submit an API request to perform the server-side update, refetch the new data, and render the newly ordered items. No sweat.

The feedback I received was that the change in the order of items happened so fast, that it would be difficult for users to even see what had happened. Could I find a way to smoothly move the items from their old positions to their new ones? This, of course, was more challenging than it may seem. React prefers to handle all rendering and document object manipulation itself. I was going to need to use some sort of escape hatch to manually move the elements off of their natural positions on the page.

Researching my options

When I’m solving a web development problem, I always ask three questions in this order:

  1. How can I make the most of native web APIs (HTML, CSS, and the DOM)?
  2. How can I make the most of the third-party libraries I’m already using?
  3. How can I limit the quantity (and ensure the quality) of any new third-party libraries I need to use?

For this solution, I assumed someone had already solved this problem in a nice, tidy way. To my surprise, my search turned up far fewer results than I had hoped. Thankfully, a couple of capable developers had documented their own solutions:

These two blog posts were immensely helpful in teaching me about some essential browser behaviors and React utilities that would allow me to implement my own solution without importing any extra libraries.

The solution

I’ve published my solution in a repository on my personal Gitea instance, which you are welcome to view or download . While I’ve published under an MIT license, I’d highly recommend NOT copy-pasting the solution into your own app. Better to read it, understand it, and then implement your own solution that will best meet your own needs.

Since everyone loves a good demo, I’ve also wrapped my solution in a little interactive web component that you can play with below!

How it works

This solution depends on a few key technologies and APIs:

  • The CSS spec gives us top to offset an element’s vertical position, and transition to smooth out changes to CSS properties.
  • The DOM APIs include getBoundingRect to get the current screen position of an element, and requestAnimationFrame to run code after the next screen paint completes.
  • React’s useRef lets us manipulate DOM nodes directly, and useLayoutEffect lets us hook into React post-render but prior to painting the screen.

Let’s break down the solution in detail now.

Getting DOM elements from React

We leverage useRef() to create a reference that will contain a mapping of element keys to element references. The keys we choose should be the same keys that we pass to the key render prop of each item in the list. This way, we can continue to reference the same React elements across re-renders.

interface ListItemRefsById {
  [id: string]: HTMLLIElement | undefined;
}

export function myComponent() {
  const itemRefs = useRef<ListItemRefsById>({});

  // rendering the list
  return (<ol>
    {items.map((item) => (
      <li
        key={item}
        ref={(li) => li === null ? delete itemRefs.current[item] : (itemRefs.current[item] = li)}
      >
        {item}
      </li>
    ))}
  </ol>);
}

Tracking element positions

We call getBoundingRect on each element reference two times for each re-ordering event. The first call occurs in the callback that causes the change in ordering. We save positions in a separate ref map as the initial positions of each element, prior to reordering. Our second call is inside our useLayoutEffect hook, to get the elements’ new positions after the reordering.

interface ItemTops {
  [id: string]: number | undefined;
}

export function myComponent() {
  /* previous code */
  const itemTops = useRef<ItemTops>({});

  useLayoutEffect(() => {
    if (!keys) return;
    keys.forEach((key) => {
      const itemRef = itemRefs.current[key];
      if (itemRef) {
        const currentTop = itemRef.getBoundingClientRect().top;
        /* Need to implement the rest of this still... */
      }
    });
  }, [items]);

  return (
    {/* code for rendering the list */}
    <button
      type="button"
      onClick={() => {
        updateItemPositions();
        mutateItems('reverse');
      }}
    >
      Remove
    </button>
  )
}

“Rewinding” element positions (temporarily)

Let’s recap our flow so far:

  1. Initial render and paint. We get references to each item’s element.
  2. Some event triggers a reordering. We save the elements initial positions before the next render.
  3. The new list of items is rendered. Before the screen paints, our useLayoutEffect() hook is called, and we get the element’s new positions.

From here, we’re still inside our layout hook. For each element, we compute the difference between its initial position and the new position that React is about to (but has not yet) paint the element to the screen. We save these computations into yet another key-element mapping, but this time we are using plain old useState rather than useRef. And guess what? Because we modified our component state, React will re-render the component before it paints to the screen! During this new re-render, we will pass top: -difference into the inline styles for each item. If we have set the items’ position to relative, they will appear in the exact same place they were before! We will also pass transition: top 0s to tell the browser to apply the new top value immediately.

interface ItemOffsets {
  [id: string]: number | undefined;
}

export function myComponent() {
  /* useRef and useState declarations */

  /*
   * If we used useState it would be too late. The elements would "flash" on the screen in their new
   * positions before the offset was applied.
   */

  useLayoutEffect(() => {
    if (!keys) return;
    const newItemOffsets: ItemOffsets = {};
    keys.forEach((key) => {
      const itemRef = itemRefs.current[key];
      if (itemRef) {
        const currentTop = itemRef.getBoundingClientRect().top;
        const prevTop = itemTops.current[key] || 0;
        const offset = -(currentTop - prevTop);
        newItemOffsets[key] = offset;
      }
    });
    setItemOffsets(newItemOffsets);
  }, [keys]);

  return (
    <ol>
      {items.map((item) => (
        <li
          key={item}
          ref={(li) => li === null ? delete itemRefs.current[item] : (itemRefs.current[item] = li)}
          style={{
            position: 'relative',
            top: itemOffsets[key] || 0,
            transition: 'top 0s',
          }}
        >
          {item}
        </li>
      ))}
    </ol>
    <button
      type="button"
      onClick={() => {
        updateItemPositions();
        mutateItems('reverse');
      }}
    >
      Remove
    </button>
  )
}

Forcing the transition

I left out a small but important step in the last section. At the end of our useLayoutHook(), we make a call to requestAnimationFrame(). This lets us set up a callback function that runs after the next screen paint. All we need to do in this callback is clear the calculated differences from our component state. This will trigger another re-render. During this render, we will skip passing top, and instead pass transition: top 2s. Now, when the browser going to paint the screen, it will detect the change in the element positions, but it will also know that it should gradually move the element from its previous position to its new one.

export function myComponent() {
  /* other code */
  
  const [itemOffsets, setItemOffsets] = useState<ItemOffsets>({});

  useLayoutEffect(() => {
    if (!keys) return;
    const newItemOffsets: ItemOffsets = {};
    keys.forEach((key) => {
      const itemRef = itemRefs.current[key];
      if (itemRef) {
        const currentTop = itemRef.getBoundingClientRect().top;
        const prevTop = itemTops.current[key] || 0;
        const offset = -(currentTop - prevTop);
        newItemOffsets[key] = offset;
      }
    });
    setItemOffsets(newItemOffsets);

    /* clear our offsets after the next screen paint */
    requestAnimationFrame(() => {
      setItemOffsets({});
    });
  }, [keys]);

  return (
    <ol>
      {items.map((item) => (
        <li
          key={item}
          ref={(li) => li === null ? delete itemRefs.current[item] : (itemRefs.current[item] = li)}
          style={{
            position: 'relative',
            top: itemOffsets[key] || 0,
            // during the same render where we remove the offset, we apply the transition property with a non-zero value.
            transition: !itemTops.current[key] || itemOffsets[key] ? 'top 0s' : 'top 1s',
          }}
        >
          {item}
        </li>
      ))}
    </ol>
    <button
      type="button"
      onClick={() => {
        updateItemPositions();
        mutateItems('reverse');
      }}
    >
      Remove
    </button>
  )
}

In case you lost track, here is what happened during our last three screen paints:

  1. Prior to re-ordering (old positions, no offset)
  2. First frame after re-ordering (new positions, but offset to old positions using top: -[DELTA]; transition: top 0s;)
  3. Second frame after re-ordering (new positions, no offset, moved gradually from old positions using transition: top 2s;)

The trickiest thing about this is distinguishing a React render, which determines how and where elements should be updated, and a browser paint, which actually updates the user’s window. There are two React renders between the first and second paint (thanks to our useLayoutEffect() hook setting some local state), then a single render between the second and third paint (because items doesn’t change, so useLayoutEffect() does not fire again).

Caveats and limitations

The source code I published was implemented to cover the minimal requirements necessary for my client’s project. So there are some major limitations:

  • I only implemented vertical animation. To include horizontal animations, just apply the same logic using the left position property as well.
  • It does not work for transitions on initial render, only for changes that happen after the first render.
  • It is not optimized for large lists. The blogs I linked discuss some ways to optimize by using CSS transform.
  • The interface for the hook I created to encapsulate this logic requires a lot of manual rigging. I’m sure with more investment someone could make it more auto-magical.

Animate away!

I hope you’ve found this blog helpful and informative! Feel free to contact me if you have any feedback or comments.


Have questions or comments about this blog post? You can share your thoughts with me via email at blog@matthewcardarelli.com , or you can join the conversation on LinkedIn .