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 ref
s. 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 ref
s 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 ref
s 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.
Discussion (0)