loading...

FLIP animation but with React Hooks

virtualkirill profile image Kirill Vasiltsov Updated on ・4 min read

UPDATE: The library API was changed, so some of the material below is outdated. Read about the newest version here

Some of you may have heard about the FLIP technique by Paul Lewis.

This technique is awesome when you need to smoothly animate things without being messed up by a busy main thread. But one of its hidden advantages is that it allows us to animate the unanimatable.

There is no CSS you can write to animate a DOM position change triggered by e.g. sorting, without also relying on something like setInterval or requestAnimationFrame. The FLIP technique makes this both possible and smooth.

But does it work with Hooks?

Shameless plug

It very well does. However, there are some tricky edge cases, so I created a package react-easy-flip that gives you the useFlipAnimation hook and painless smooth animations with it. (And it is very small, just 807B!)

Here is a small demo of it in work: https://flip.jlkiri.now.sh/

useFlipAnimation

To properly perform a FLIP, we need to at least

a) keep the previous state (like DOM positions) of animated elements somewhere, and
b) have access to new DOM positions before DOM has a chance to paint

In olders version of React this was achievable with this.setState to store the current state of elements we want to animate by using their refs. On next render, you would access new DOM state in componentDidUpdate and perform a FLIP.

In newer versions (16.8.0 and higher), we can still use something like useState and setState to store previous state of animated elements. But what is the best place to access DOM before it paints? According to docs, this is useLayoutEffect.

This is all information we need to realize FLIP with Hooks.

Ideally, we need a hook to which we can pass a reference to our parent element, that contains children we want to animate. This allows us to avoid having refs to each child. Next, we want to specify animation details like transition duration or easing function. Finally, we need to tell it to only apply changes when dependencies change. Something like this:

function App() {
  const [items, setItems] = useState(["A","B","C"])
  const rootRef = useRef()

  useFlipAnimation({
    root: rootRef,
    opts: { transition: 700 },
    deps: items
  })

  return (
    <div ref={rootRef}>
      {items.map((item) => {
        return <div>{item}</div>
      })}
    </div>
  )
}

Note: in real world, you must provide proper keys!

So, how should our useFlipAnimation hook look inside?

Suppose we want to animate positions of entries in a TODO list when we sort it.
First of all, since we do not keep children refs we need to store it somewhere by accessing our parent ref. setState and useState is one such option, but it causes unneeded re-renders. Another, better option, is to use useRef to keep a simple object across renders, but that will not cause a re-render when we mutate it. To identify each child we also need some special prop. I think that data attributes, e.g. data-id, is a reasonable choice here.

Now, inside our hook we can do this:

const childCoords = useRef({ refs: Object.create(null) })

useLayoutEffect(() => {
    const children = root.current.children // parent ref we passed as an argument
    for (let child of children) {
      const key = child.dataset.id
      childCoords.current.refs[key] = child.getBoundingClientRect()
    }
}, [items])

Where should we put animation logic? The best place to put it is inside the same useLayoutEffect callback, but above the part where we save DOM positions. Our animation logic will check where old positions exist, and only apply if they do.

    requestAnimationFrame(() => {
      for (let child of children) {
        const key = child.dataset.id

        // Check whether old positions exist
        if (key in childCoords) { 
          const coords = childCoords[key]

          // Calculate delta of old and new DOM positions for transform
          const prevX = coords.left
          const prevY = coords.top

          const nextX = child.getBoundingClientRect().left
          const nextY = child.getBoundingClientRect().top

          const deltaX = prevX - nextX
          const deltaY = prevY - nextY

          invert(child)({ dx: deltaX, dy: deltaY })

          requestAnimationFrame(() => play(child))
        }
      }
    })

The functions play and invert can be whatever you want as long as they actually FLIP. Here is an example that is useful for "flipping" top and left position changes.

    const play = function play(elem) {
      elem.style.transform = ``
      elem.style.transition = `transform ${transition}ms ${easing} ${delay}ms`
    }

    const invert = function invert(elem) {
      return function _invert({ dx, dy }) {
        elem.style.transform = `translate(${dx}px, ${dy}px)`
        elem.style.transition = `transform 0s`
      }
    }

Apart from the above, you may need to make sure your parent exists (= its ref.current is not null), or that it has one or more children.

Caveats

There a couple more caveats I didn't mention here for the sake of simplicity. In real world, you want to track window resizes and cases where re-render occurs while children are still animating. This can be quite tedious, and this motivated me to write the library I mentioned above.

There are a few React libraries that do animations with FLIP. react-flip-toolkit is amazing and provides many kinds of FLIPped CSS animations. It also includes a vanilla package for people who do not use React.

Posted on Oct 27 '19 by:

virtualkirill profile

Kirill Vasiltsov

@virtualkirill

I am interested in everything that is related to UI. I'm also learning about low-level stuff and experimenting with Rust.

Discussion

markdown guide