Web animation wizardry
Picture this: a bunch of developers, most of them back-enders, are given a Figma design file to turn into the new homepage for Flare. We set out to build our brand-new marketing site with a sprinkle of animations. Here is how we crafted them.
If you have not seen the new homepage, check it out before reading this. Try scrolling slowly and quickly to catch all of the little details. It's worth it, I promise.
Sticky navbar
The design files had a different state for the navigation menu for when the page is not scrolled yet and when it is scrolled. To transition between them, we used a custom hook to determine when the page is scrolled to transition the width of a wrapper div from 100% to 0%. To prevent the menu from actually going to 0% we set a min-width: max-content
, which scales down the container to the size of the actual content.
– Don't you love CSS?
Error card carousel
We went through a lot of iterations on this one, but I feel like we landed on a nice and easy 3-step solution.
1. Positioning
Each card is positioned at the same position by using a single-cell grid layout. This prevents layout shifts because the wrapper will be sized based on the largest card.
Tailwind classed to achieve a single-cell grid layout:
<div class="grid grid-cols-1 grid-rows-1">
<article class="row-start-1 col-start-1">Card 1</article>
<article class="row-start-1 col-start-1">Card 2</article>
<!-- ... -->
</div>
2. Offset
CSS variables are used to make the offsets configurable.
.cardstack {
--card-offset: 2rem;
--scale-factor: 0.07;
--opacity-factor: 0.23;
}
Each card is scaled and translated based on its index in the stack.
.cardstack > article {
will-change: transform, opacity;
transform: translateY(calc(var(--index) * -1 * var(--card-offset)))
scale(calc(1 - var(--index) * var(--scale-factor)));
opacity: calc(1 - var(--index) * var(--opacity-factor));
transition: transform 0.5s ease-in-out, opacity 0.5s ease-in-out;
}
The index can be tracked through the :nth-child()
selector, combined with a preprocessor like sass this can be done with a simple for loop.
@for $i from 1 through 5 {
.cardstack > article:nth-child(#{6 - $i}) {
--index: #{$i};
}
}
Alternatively, you can write it out by hand:
.cardstack > article:nth-child(1) { --index: 5 } /* backmost card */
.cardstack > article:nth-child(2) { --index: 4 }
.cardstack > article:nth-child(3) { --index: 3 }
.cardstack > article:nth-child(4) { --index: 2 }
.cardstack > article:nth-child(5) { --index: 1 }
.cardstack > article:nth-child(6) { --index: 0 } /* topmost card */
3. Rotate the stack
The topmost card (last in the list) is added to the back of the stack (top of the list). The trick here is to make the React component key follow each card. This prevents the cards from unmounting and allows the cards to transition smoothly.
Epic screenshot
Arguably the most epic transition on this page is the screenshot reveal.
This animation can be broken down into four steps.
1. Track the scroll position
Framer-motion provides a useScroll
hook that can track the vertical scroll progress of the screenshot in the viewport. Setting the offset
option to ["start start", "start end"]
maps the scroll position from the start of entering the viewport to when the element is entirely inside of the viewport.
2. Smoothly map the progress to transformations
Framer Motion handles binding the translation and rotation of the element to the vertical scroll progress from step 1.
First, we use the useTransform
hook to map the vertical scroll progress from an input range to an output range. Then the useSpring
hook handles smoothly snapping the values to the actual scroll position.
3. Transform in 3D
To achieve the final effect, we set the css perspective
property to move the user (you) away from the z0 plane to give the 3D-positioned element perspective. The transform origin is set to the bottom of the element to make it smoothly transition from moving towards z0. Now the screenshot is ready for z-translation and x-rotation.
4. Blur
The same Framer Motion hooks bind the blur value to the element. The input range is shortened to make it readable earlier in the animation.
Retrospective
Getting these animations to perform well for a marketing site was challenging. On our way, we ran into layout shifts, browser inconsistencies regarding positioning and scroll tracking, performance problems due to background blurs and complex SVGs, and many more papercuts that slowed us down.
We hoped Framer Motion would be a drop-in fix for all of these problems, but using regular CSS ended up being easier to develop and debug.
Ultimately, we came up with solutions for these challenges and learned a lot. I hope you learned something too.