3
0

More than 1 year has passed since last update.

Animating Lists in React: features comparison and opinion

Last updated at Posted at 2021-12-21

I think rendering lists of data it's a very common pattern for every application and therefore we as front end engineers have to deal with the most efficient way of displaying these lists, depending on the number of items, the kind of data we are displaying (images, numbers, text, inputs, etc.) and how the user will interact with these lists, by sorting them, deleting items, adding items, and all of those fun CRUD operations we're all familiar with.
annie-spratt-AE1XpXLxXSA-unsplash.jpg

Let's begin by defining the tech stack for this comparison:
- Core environment
- React 17 - using create-react-app + typescript -
- @stitches/react - CSS-in-JS library with SSR (just for styling)
- react-use-hooks - Just hooks for handling the list manipulation
- nanoid - package to generate random unique ids, just for adding and removing the dummy elements
- Handling transitions
- react-transition-group - "Exposes simple components useful for defining entering and exiting transitions" - package mantained by the react community
- animation libraries
- Anime JS - Very popular animation library with a flexible api and multiple options
- Motion One JS - lightweight and modern animation library made by the creator of framer-motion
- framer-motion - Production-ready declarative animations.
- react-flip-move - Animation module that handles list animation (excluseve

Of course there are tons more of alternatives (react-spring, gsap, popmotion, velocity js, but I won't be covering all in this posts, I will cover the ones I prefer and I have worked before to achieve nice animations

I have setup a repository and a deployment to vercel so you can check out the examples by yourself:
github: https://github.com/IrvingArmenta/animating-lists-react
example: https://animating-lists-react-cosmos.vercel.app/
animatinglistgif.gif

experiment implementation with CRA and typescript

I want to make clear that we won't manage animations with CSS at all. my main reason is the usage of callbacks and the async control to have over animations. There are callbacks for CSS animation yes, but they're not as common and doing duration management because cumbersome, that being said, let's continue.

Animation Lists

Animejs + react-transition-group

First let's analyze the setup using animejs and react-transition-group
Here it's a very simplified version for the anime js list source code AnimatedLists/lists/AnimeJsList.tsx
actual source code in github

import { Transition, TransitionGroup } from 'react-transition-group';
import anime from 'animejs';

const AnimeJsList = () => {
  return (
    <ul>
      <TransitionGroup component={null}>
        {items.map((item, index) => {
          const liRef = createRef<HTMLLIElement>();
          return (
            <Transition
              timeout={{ enter: duration.enter, exit: duration.exit }}
              key={item.id}
              nodeRef={liRef}
              onEnter={() => {
                if (liRef.current && parentUlRef.current) {
                    anime({
                      targets: liRef.current,
                      opacity: [0, 1],
                      height: [0, liRef.current.offsetHeight],
                      marginTop: [0, 8],
                      duration: duration.enter
                    });
                  }
                }
              }}
              onExit={() => {
                if (liRef.current) {
                  anime({
                    targets: liRef.current,
                    duration: duration.exit,
                    opacity: 0,
                    height: 0,
                    marginTop: 0,
                    translateY: 0
                  });
                }
              }}
            >
              <li ref={liRef} key={item.id}>
                <NameHolder i={index} />
                <p>key: {item.id}</p>
                <button onClick={() => remove(item.id)}>
                  <DeleteIcon />
                </button>
              </li>
            </Transition>
          );
        })}
      </TransitionGroup>
    <ul/>
  );
};

As you can see, we are using <TransitionGroup /> and <Transition /> to wrap the {items.map()} array. Here we are listening for the onEnter and the onExit callback of the <Transition /> component.

Before the usage of hooks <Transition /> used to get the child node elements through the findDomNode api, which is now deprecated in strict mode, so if you try to use it as before, it will throw a warning/error, therefore, you need to setup the usage by setting the nodeRef={} prop where the ref will be a a reference to the HTML node, in this case the <li /> inside the list.

const liRef = createRef<HTMLLIElement>();
...
<Transition nodeRef={liRef}  />
...
<>
  <li ref={liRef} key={item.id}>
     <NameHolder i={index} />
     <p>key: {item.id}</p>
     <button onClick={() => remove(item.id)}>
       <DeleteIcon />
     </button>
  </li>
</>
...

In order for the reference to work for each <li /> item, we need to use createRef inside the array map, since we are creating and removing elements createRef make these refs dynamically for us.
on both onEnter and onExit callbacks we are calling the anime() function to apply the JS animation:

anime({
    targets: liRef.current,
    opacity: [0, 1],
    height: [0, liRef.current.offsetHeight],
    marginTop: [0, 8],
    duration: duration.enter
 });

targets it's the reference to the DOM of the <li /> element, but we could target other elements inside the list, or other multiple selectors, like #id or .class selectors.

We set all of the props to start with [0] and then animate to the end values, setting an array in the values, this it's equivalent to set the style immediately, soopacity will start on 0 and animate to 1 , height will animate from 0 to the actual <li /> offsetHeight, this is a way to achieve animation to 'auto' without setting a fixed height.

All of these values will be animated at the same duration (800ms in the example) and the default easing by animejs

animejs default easing it's a weird "elastic" easing, so sometimes it's hard to understand the animation timing, therefore I would recommend setting it to linear and try other easings from there.
https://animejs.com/documentation/#linearEasing

the animation function has multiple callbacks that can help us orchestrate other side effects, such as complete , update and begin . these are extremely helpful when we want to handle events after an animation has concluded or when it has reached a certain point.

I won't be going super deep in explaining the functionality of each approach, so please if you're interested in animejs read their documentation: https://animejs.com/documentation

animejs-stag.gif

Let's review this approach:

-- bundle size --
bundlephobia - animejs - Minified and gzipped - 6.9kb
buundlephobia - react-transition-group - Minified and gzipped - 4.4kb
Total bundle size: 6.9kb + 4.4kb = 11.3kb
My opinion
I think this approach for adding animation into your application gives you the flexibility to not just animate lists, but also other so many details, however, it lacks a lot of modern features, like dragging, sorting, and layout based animations, these have to be implemented by yourself, if you don't need such features, this is a very solid way to build some animation into your components!

it also has a very well explained documentation and support from a big community.


Motion JS + react-transition-group

This approach is very similar to the one above, so I won't be explaining a lot of it, there are some key differences though.
Here it's the simplfied version, just like the animejs one, file name is: AnimatedLists/lists/MotionJsList.tsx - actual source code

import { animate } from 'motion';
import { Transition, TransitionGroup } from 'react-transition-group';

const MotionJsList = () => {
  return (
    <ul>
      <TransitionGroup component={null}>
        {items.map((item, index) => {
          const liRef = createRef<HTMLLIElement>();
          return (
            <Transition
              timeout={{ enter: duration.enter, exit: duration.exit }}
              key={item.id}
              nodeRef={liRef}
              onEntering={() => {
                if (liRef.current && parentUlRef.current) {
                    animate(
                      liRef.current,
                      {
                        opacity: [0, 1],
                        height: [0, `${liRef.current.offsetHeight}px`],
                        marginTop: [0, `${8}px`]
                      },
                      { duration: duration.enter / 1000 }
                    );
                  }
                }
              }}
              onExit={() => {
                if (liRef.current) {
                  animate(
                    liRef.current,
                    {
                      opacity: 0,
                      height: 0,
                      marginTop: 0
                    },
                    { duration: duration.exit / 1000 }
                  );
                }
              }}
            >
              <li ref={liRef} key={item.id}>
                <NameHolder i={index} />
                <p>key: {item.id}</p>
                <button onClick={() => remove(item.id)}>
                  <DeleteIcon />
                </button>
              </li>
            </Transition>
          );
        })}
      </TransitionGroup>
    </ul>
  );
};

As you may notice already, the setup is quite similar to theanimejs one, however, the function that applies the animation for the node reference has a different name (anime => animate) and different syntax:

animate(
      liRef.current,
      {
       opacity: [0, 1],
       height: [0, `${liRef.current.offsetHeight}px`],
       marginTop: [0, `${8}px`]
      },
      { duration: duration.enter / 1000 }
  );

animate takes the node reference as the first attribute, then followed by the actual animation values, these are quite similar to animejs in the sense that we are also setting [0] at the beggining to specify that we want to start from that point to the next at the end of the array.

the animate function do not take number (unless is 0) as a valid type to animate to, it needs a unit string, just like we are doing here with the offsetHeight, ${liRef.current.offsetHeight}px, if we don't do this, it won't know what value to apply, but it can be anything from px to rem to em etc.

Another thing to point at, it's that the duration value is set in a different object attribute, after the animation values, (animejs just put all of those in one single object), also, duration is measured in seconds and not milliseconds like animejs , therefore we have to divide the 800ms by 1000 in order to get the 0.8s value.

Motion One was made. by the actual creator of framer-motion, as a more web browser native experience, appealing to the (always changing) WAAPI (web animations api) it applies animations in the most performant and efficient way, using the GPU layers to not overload the main JS thread, and providing the most modern way of interacting with the WAAPI.

motion-js-thread.gif

Animations with motion one do not depend in the main JS thread, you can see their example in their website where you can block Javascript and it won't interrupt the 60fps animation,
more information here: https://motion.dev/guides/feature-comparison#hardware-acceleration

Let's review this approach:

-- bundle size --
bundlephobia - motion - Minified and gzipped - 5.5kb
buundlephobia - react-transition-group - Minified and gzipped - 4.4kb
Total bundle size: 5.5kb + 4.4kb = 9.9kb ( 1.4kb smaller than animejs)
My opninion
If you're concerned about bundle size, I think this is the best approach in 2021, however, there is not much support from legacy browsers, like IE11 and the features are also lacking in comparison to animejs or gsap, therefore I'm not sure if it's ready for production.

I would keep an eye for the improvement and any future releases, since I think it's becoming the most performant and updated package out there.


Framer motion

framer motion it's a whole new different approach since it does not depend on react-transition-group to handle the animations, it has it's own way for watching exits and enterings of elements in a list, and we will see a simple setup for lists in the next code example:

This example uses framer motion 4 since create-react-app has no support for esm packages and there are some conflicts, in framer motion 5 the AnimateSharedLayout component is removed and any component with the layout prop works smartly without the wrap, more information: https://www.framer.com/docs/guide-upgrade/##esm-and-create-react-app

I will paste here the simplified version of the source code, but you can go to the actual source code - AnimatedLists/lists/FramerMotionList.tsx

import { AnimatePresence, AnimateSharedLayout, motion } from 'framer-motion';

const FramerMotionList = () => {
  return (
    <ul>
      <AnimateSharedLayout>
        <AnimatePresence initial={false}>
          {items.map((item, index) => {
            return (
              <motion.li
                key={item.id}
                initial={{ opacity: 0 }}
                animate={{
                  opacity: 1,
                  transition: { default: duration.enter / 1000 }
                }}
                exit={{
                  opacity: 0,
                  transition: { default: duration.exit / 1000 }
                }}
                layout={true}
                onAnimationStart={() => onFramerAnimationStart(item.id)}
              >
                <NameHolder i={index} />
                <p>key: {item.id}</p>
                <button onClick={() => remove(item.id)}>
                  <DeleteIcon />
                </button>
              </motion.li>
            );
          })}
        </AnimatePresence>
      </AnimateSharedLayout>
    <ul/>
  );
};

framer motion has it's own component to check for "presence", this handles the exit and animate states of the elements, it's called <AnimatePresence />

framer motion animations need the motion helper to use as DOM references, this is how framer motion it's aware of the presence of these items, therefore the simple <li /> became <motion.li /> but you can render any DOM HTMLElement, such as <motion.div /> or <motion.ul /> etc.

animate works as the onEnter callback for new elements and exit works as the onExit callback for removed elements in the list.

As you can see here, we are just defining an opacity animation, nothing else, and a duration of 0.8s just like motion js,

 <motion.li
    key={item.id}
    initial={{ opacity: 0 }}
    animate={{
       opacity: 1,
       transition: { default: duration.enter / 1000 }
    }}
    exit={{
        opacity: 0,
        transition: { default: duration.exit / 1000 }
     }}
    layout={true}
    onAnimationStart={() => onFramerAnimationStart(item.id)}
 >

Instead of passing array values as previous examples ([0, 8]) we are setting the initial={{ opacity: 0 }} value from where the animation will happen when a new <motion.li /> it's added, animate={} will animate it to opacity: 1 and exit={} will animate to 0 when removed.

Notice the layout={true} prop, in this case, we are listening for the changes in all the motion.li elements, in order for the list to smoothly re-arrange itself, if layout is set to false the elements will abruptly change, this is why, in this example, we don't need to set a height animation for a smooth transition, pretty convenient!

<motion /> elements have listeners for animation end and animation start, there is also an specific listener for the layout change animation, for more information: https://www.framer.com/docs/component/##animation-events

Let's review this approach:

-- bundle size --
bundlephobia - framer motion - Minified and gzipped - 41.9kb
Total bundle size: 41.9kb - [32kb more than motion js and react-transition-group ] :scream:

scared-for-bundlesize.gif
the face of bundle size phobia...

Bundle size difference is huge :open_mouth: however, there are multiple alternatives to reduce the impact of the package, you can learn more here: https://www.framer.com/docs/guide-reduce-bundle-size/
the bundle size could be reduced to even 13kb, however, it depends on the features you want to use, like drag and dom interactions

My Opinion
As the package says itself, framer-motion it's a production ready animation library, it comes with a bundle of really nice features, a lot of seems like magic, and that's when some of the issues come up, there is a lot of "magic" going on that sometimes customizing to your needs, can become specially difficult.
In the other hand, it's extremely helpful and I woulnd't mind it using it in my next react project (I would use the lazy loading though)


React flip move

react-flip-move it's an old (2016!) but still functional package (until certain degree...)
In comparison with the other approaches, react-flip-move it's a library that has the main purpouse in animating lists with the Flip (First, Last, Invert, Play.) technique, the animation runs smoothly and the layout adapts itself.

Let's check the source code (remember, simplified source code!):
here it's the actual code AnimatedLists/lists/FlipMoveList.tsx

import FlipMove from 'react-flip-move';

const FlipMoveList = () => {
  return (
    <ul>
      <FlipMove typeName={null} onStart={(el) => onFlipMoveStart(el)}>
        {items.map((item, index) => {
          return (
            <li key={item.id}>
              <NameHolder i={index} />
              <p>key: {item.id}</p>
              <button onClick={() => remove(item.id)}>
                <DeleteIcon />
              </button>
            </li>
          );
        })}
      </FlipMove>
    </ul>
  );
};

Here you can see that the code for the FlipMove component it's the "simplest" one of all previoous examples, this is because FlipMove still uses the old findDomNode api, therefore, we will get the deprecation warning/error in strict mode.
FlipMove also uses an old API, componentWillReceiveProps which has also already being deprectated and marked as UNSAFE_ , so I don't think FlipMove it's available for production anymore, unless it gets mantained.

unlike the other libraries, react-flip-move does not have exactly defined styles for enter or exit, it uses various pre-defined animations and values for element transitions. You can set the next values:
- easing
- duration
- delay
- staggerDurationBy
- staggerDelayBy

These gives you less specific control for the elements but the presets and the "experiment" here can give you an idea of how these values affect the animations : Lab

 <FlipMove typeName={null} onStart={(el) => onFlipMoveStart(el)}>

In the setup for <FlipMove /> we have set the typeName={null} to null in order to not wrap the elements in any other HTML element. by default <FlipMove /> renders a <div /> that wraps the children.

It has multiple event listeners, one example is onStart() , however you need more implementation to get the entering element reference correctly, since it seems this event listens each element in the list.

react-flip-move and framer motion both support the animation of re-ordering the list out-of-the-box, no need for any kind of extra implementation. animejs and motion needs some way to understand the position of the elements and from there animate and properly move. Here it's an article that may lead to that implementation, however I don't cover that in this blogpost -> https://itnext.io/animating-list-reordering-with-react-hooks-aca5e7eeafba

let's review this final approach:

-- bundle size --
bundlephobia - react-flip-move - Minified and gzipped - 5.5kb
buundlephobia - react-transition-group - Minified and gzipped - 4.8kb
Total bundle size: 4.8kb [ the smallest alternative for lists animation in this post ]

My Opinion
I think react-flip-move it's a great library for animating lists, the re-ordering feature comes included and animations are quite performant, however, at 2021, it has become stale and deprecation notices are flowing, I'm not sure it will be available for React 18. In conclusion I wouldn't recommend it for future projects and would look for more a modern implementation if possible, it seems the creator encourage the usage of another library -> react-flip-toolkit

Conclusion

As I said in the beginning, displaying lists of data is a common pattern in the front end work, and animation make these data to be more presentable and attractive to users.

I always recommend implementing these kind of animations, it really changes the feeling for the end users when interacting with data, and I think it's also part of the idea of micro-interactions which I really love, perhaps I should write a blog post about it.

I'll write a table with some points and features (good and bad) for each approach

RTG = react-transition-group

library/feature Anime JS + RTG MotionJS + RTG Framer motion react flip move
Bundle size 11.3kb 9.9kb 41.9kb 4.8kb
SSR (no style flashing) :x: :white_check_mark: :white_check_mark: :x:
Flexibility (usage besides lists) :white_check_mark: :white_check_mark: :white_check_mark: :x:
Good documentation :white_check_mark: :warning:
(some docs are not exposed )
:white_check_mark: :x:
Simple setup :x: :x: :white_check_mark: :white_check_mark:
Best GPU handling :x: :white_check_mark: :x: :white_check_mark:
List re-ordering animation :x: :x: :white_check_mark: :white_check_mark:

For more specifics in motion differences, they have this page right here:
https://motion.dev/guides/feature-comparison.


thanks-american.gif

Thank you very much for your time and the interest in this post, let me know if you have any interest in animation an which approach it's the better one for you? (of course I think depends on the project and needs!)

Here are again, both github links and the deployment in vercel for this simple post!
github: https://github.com/IrvingArmenta/animating-lists-react
example: https://animating-lists-react-cosmos.vercel.app/

Irving.

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0