I used to think shared layout animations are very complicated and involve a lot of complex calculations.
While that is true, Motion makes it very easy and abstracts all of the complexity away. I recommend reading Inside Framer's Magic Motion by Nanda if you're interested to see how all of the calculations are done under the hood.
Structure
The way I structured this component is simple. I created an expanded and collapsed state components and a collection image component.
Collection Image
This component is shared between both states, so there is some state specific functionality that will get covered later.
Other than that, it's very straight-forward. I used the Image component from Next
wrapped in a motion.div and an absolutely positioned inset border.
The collapsed state contains invididual images that are absolutely positioned and stacked on top of one another. Hovering the item sets isHovered to true which in turn animates each image individually.
Each item has custom rotate, x and y values that are animated on hover.
I mapped over the images with the exception of the collection avatar, because it behaves differently in the expanded state. Each item also has a unique layoutId for the cross-state animation.
Everything is wrapped in a div that triggers the animations on hover and an onClick that changes the state.
The hover effect in the expanded state is the most complex part of this interaction.
I started off by tracking the mouse/touch position in the expanded state relative to its container. This value is then stored in a mouseLeft motion value.
The position is updated on both mouse movement and touch events.
Then in the Collection Image component, I use useTransform which is a Motion hook that creates a derived motion value that updates whenever its dependencies change.
For each image, I get its position offsetLeft and width offsetWidth from the DOM.
The distance is then calculated by taking the mouse position mouseLeft.get(), subtracting the image's left edge position bounds.x and subtracting half the image's width bounds.width/2 to get the distance from the center.
scale transformation and offset animation need to be handled as well. What this basically does is that if the cursor is far away from the image the scale stays at 1.
When the cursor is directly on top of the image, i.e. distance=0, the image scales up to it's max scale. The scale interpolates between these values as the mouse moves.
Then, the calculateOffset function creates the "push away" effect. This function returns 0 when there's no mouse interaction, pushes images away from the mouse when they're far away and transitions between these states based on both the distance and the current scale.
The NUDGE constant determines how far the images are pushed away, and Math.sign(currentDistance) ensures images are pushed in the correct direction (away from the mouse).
These constants are the "magic numbers" that control how the animation feels. They determine how much the image scales up, how far the mouse needs to be to affect the images and how far the images are pushed away from the mouse.
Finally, I wrapped everything in another MotionConfig as well as AnimatePresence with the popLayout mode, added keys and render the correct content based on the state.