Transitioning between 2 different elements with FLIP
CSS transitions are great, but they aren't always sufficient for more complex animations. Imagine a situation where you want to reorder a list of items, how would you write the CSS transitions for it?
Consider the following kanban board. Try clicking on the tasks to move them around!
The animation isn't trivial to write with CSS because of 2 main reasons:
- In most cases, the animated element isn't actually one element. They are 2
totally different elements, though representing the same entity; in this
case, a
Task
- The animation can not be statically defined. The position of the element is dynamic. The amount of pixel we need to move the element by is dependent on how many items are in the list, media queries, viewport size, document scroll position, etc.
We definitely need to write some JavaScript to help with this, but how?
The FLIP technique
FLIP is an acronym first coined by Paul Lewis (read the article: FLIP Your Animations). It stands for First, Last, Invert, Play.
- First โ we need to know the initial state (position, size, etc) of the element we're animating.
- Last โ the final state of the element
- Invert โ find what changed between Last and First, craft a transformation to invert it so the final state looks like the initial state
- Play โ remove the inversion and let the animation play!
It might sound a bit complicated for now, but I promise, once it clicks for you, it would make so much sense that you probably won't forget the concept ever again!
Let's read the following story of a frontend engineer to understand more about FLIP.
ProfileCard
to ProfileHero
FLIP-ing from Imagine we are a frontend engineer at a super famous social network platform,
Tweaker. In various parts of the web app, there are ProfileCard
components that look like the following:
ProfileCard
componentWhen the name on the card is clicked, it would go to a profile page, which
contains a ProfileHero
component.
ProfileHero
componentFor the longest time, we have just been switching between the 2 elements
immediately, without any animation, and it has been working fine! Try clicking
on the name in the ProfileCard
and the back arrow in the ProfileHero
to
navigate between the 2 states.
One day, in the daily stand-up call, the PM proposes an idea.
PM: "Let's add a feeling of continuity when the user navigates from the card to the profile page. Make the navigation feels more seamless!"
Designer: "That sounds great! Maybe we can animate the profile picture that exists in both states? I have a mock-up here, what do you think about it?"
(Try navigating between the 2 states in the component above)
PM: "That... looks... AWESOME! I am sure our very capable frontend engineer can implement it in one day, right? Right!?"
You: "Uh.., I am not sure if that is possible. You see, technically they are 2 different elements."
PM: "Huh, I am sure you can figure it out. You have our full support, just make sure it's deployed by tomorrow!".
You: "..."
The call finishes. You left the call all frustrated because the PM proposed an idea that is just making your life harder. You aren't even sure if it's possible to implement it. You go for lunch to clear off your mind. You need to take a break before continuing working because of the frustration. You think to yourself, "I really need to update my CV to get away from this PM.". But then, you remembered a blog post you read the other day โ FLIP!
You recalled that animating between 2 different elements should be possible with the FLIP technique! You left your lunch on the table. All excited with an idea to try, you sprint back to your work desk and start bashing on your mechanical keyboard, typing into your favorite code editor... Notepad.
First
First, you figured you need to store the position of the image in the first
state. You open MDN docs like a good developer you are and found that what you
need is available via the
getBoundingClientRect()
method.
// `img1` here is the profile picture element
img1.getBoundingClientRect();
/*
* DOMRect {
* bottom: 380,
* height: 60,
* left: 407,
* right: 467,
* top: 320,
* width: 60,
* x: 407,
* y: 320,
* }
*/
Last
Then, you do the same thing with the profile picture in the second state.
// `img2` here is the profile picture element in the second state
img2.getBoundingClientRect();
/*
* DOMRect {
* bottom: 572
* height: 120
* left: 255
* right: 375
* top: 452
* width: 120
* x: 255
* y: 452
* }
*/
Ah, that's super helpful! You now know that the profile picture was initially
positioned at x: 320, y: 407
, and in the second state, it is positioned at
x: 452, y: 255
. You also now know that the first profile picture has a size of
height: 60, width: 60
, and the second one has a size of
height: 120, width: 120
.
Invert
With that information, you can calculate the transformation you need, to make it seem like the profile picture in the second state is exactly the same as the first state.
// In practice, only one of img1 and img2 will exist at the same time,
// since one of the elements will be removed from the DOM already when the other one appears.
// You should cache the DOMRect of the previous state somewhere to make it work.
// In this snippet, we assume img1 and img2 both exist at the same time for the sake of simplicity.
const firstDOMRect = img1.getBoundingClientRect();
const secondDOMRect = img2.getBoundingClientRect();
// How much is the difference between the 2 positions?
const deltaX = secondDOMRect.x - firstDOMRect.x;
const deltaY = secondDOMRect.y - firstDOMRect.y;
// How much is the difference in their size?
const deltaScaleX = secondDOMRect.width / firstDOMRect.width;
const deltaScaleY = secondDOMRect.height / firstDOMRect.height;
With those numbers, you can now write a tranformation to invert the position and size of the second profile picture so that it looks like it is the first profile picture.
const invertedDeltaX = -deltaX;
const invertedDeltaY = -deltaY;
const invertedDeltaScaleX = 1 / deltaScaleX;
const invertedDeltaScaleY = 1 / deltaScaleY;
img2.style.transform = `translate(${invertedDeltaX}px, ${invertedDeltaY}px) scale(${invertedDeltaScaleX}, ${invertedDeltaScaleY})`;
With that inverting transformation in place, you have something like this:
(Try navigating between the 2 states in the component above)
Notice that with the inverting transformation, you have made the profile picture in the second state look exactly like the profile picture in the first state! Now for the final piece...
Play
We now need to undo the inverting transformation that we've done, so that the
profile picture in the second state will be restored to where it is supposed to
be placed. This is as simple as it sounds, just remove the transformation!
Though, we need to explicitly wait for the previous inverting transformation to
be painted to the DOM first before removing the transformation. We do this with
requestAnimationFrame()
.
The callback passed to requestAnimationFrame()
will only be invoked once the
browser has painted a frame.
// Note that we are actually using 2 requestAnimationFrame here
// to work around a behavior difference in firefox.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
img2.style.transition = 'transform 0.3s';
img2.style.transform = `none`;
});
});
With that in place, now we have something to show to our PM and designer!
(Try navigating between the 2 states in the component above)
Applying styles manually via JavaScript can be ugly. You would also need to take
care of cleaning up the styles after you are done with them. Not to mention that
you also need to use requestAnimationFrame()
to schedule them correctly.
Fortunately, we can avoid all that! A better way to apply the inverting transformation and schedule the removal of the transformation is by using the Web Animation API.
// Using the Web Animation API
img2.animate(
// Array of keyframes, in this case, we only need 2.
[
// The first keyframe contains the inverting transformation
{
transform: `translate(${deltaX}px, ${deltaY}px) scale(${deltaScaleX}, ${deltaScaleY})`,
},
// The second keyframe undo the inverting transformation
{ transform: 'none' },
],
{ duration: 300, easing: 'ease-in-out' },
);
Refer to the Web Animation API docs on MDN for more details.
Epilogue
You feel satisfied. The fact that you managed to find the solution to a problem you initially thought might be impossible to solve has boosted your confidence. You feel like you have leveled up and are pretty pleased with the situation.
The next day, you attend the daily stand-up call. With the confidence of a rockstar developer, you mentioned that you have pushed the changes to production. You share your screen in the call and show the new fancy animation. Everyone is impressed. "Good job!", says the PM. The PM then tries it on their own device. And... sure enough, they found a bug with the animation.
Surprised by the bug, you apologize and tell the team that you will work on a fix ASAP. You lose your confidence immediately and now are back to feeling like an impostor. "Maybe it's not the time to update my CV yet after all...", you think to yourself.
Closing
There you go! That's essentially what FLIP is and how it works. It's worth
noting that the technique also works for animating page navigation (e.g.: Going
from /
to /profile
page). This makes some interesting client-side page
navigation experience possible!
Note that the code snippets we have been using in this post are highly simplified. In practice, we would need to take more things into account to make it work well. For instance:
- Viewport scroll position (
document.documentElement.scrollTop
) - Calculate
deltaCenterpointX
anddeltaCenterpointY
instead of justdeltaX
anddeltaY
. This is needed to handle cases where the 2 states have different aspect ratios.
We also haven't handled cases where the 2 states have different
backgroundColor
, color
, etc. To animate between the 2 colors (or other CSS
properties) smoothly, we can utilize
getComputedStyle()
to get the style information. We can then use the information to animate between
the 2 colors or any other CSS property we might want to animate!
Writing a library to automatically handle FLIP animation for any given 2
elements is a huge undertaking in itself, and is not in the scope of this
writing. Though, if you are interested in the tiny FLIP library I wrote for this
writing, you can check them out
here.
While it's written to be used with React, it should be pretty easy to write a
wrapper around it to make it work with other frameworks as the core
logic has
been extracted out.
If you are looking for a more robust react-based library to do this, I'd suggest
taking a look at
framer-motion
's layoutId
and/or react-flip-toolkit
.