Full-page scroll animation with Framer Motion, React, and Tailwind CSS
In this post, I'll explain how I built the scroll-based animation for Bolt.Earth's landing page. You can see it in action here.
When I first saw what the design team had come up with, I felt a bit intimidated, having never worked on anything like it before. But I always like a good challenge, so I started researching. Although I did find some resources that helped me get started, I couldn't find a complete example that I could use as a reference. So I decided to write this article to help others who might be trying to build something similar. I hope you find it useful!
The Layout
The layout is pretty simple. It consists of:
- A parent container with a
relative
position and a height of500vh
(5 times the viewport height to fit 5 full height slides). - 5 divs with an
absolute
position and a height of100vh
. These will help with scroll snapping, and are not visible to the user. - A div with
sticky
position and a height of100vh
. This is where the animation happens.
To accomodate for the sticky header that has a height of 80px
, we'll offset the top
position of all containers by 80px
, and subtract 80px
from their heights. Here's what the code looks like:
const HeroSection = () => {
return (
<section
id="parent-container"
className="relative h-[calc(5*(100vh-80px))] w-full"
>
{/* Scroll snap helpers */}
{Array(5)
.fill()
.map((_, i) => (
<div
key={i}
className={cn(
"pointer-events-none absolute left-0 h-[calc(100vh-80px)] w-full snap-start scroll-mt-[80px]",
i === 0 && "top-0",
i === 1 && "top-[calc(100vh-80px)]",
i === 2 && "top-[calc(2*(100vh-80px))]",
i === 3 && "top-[calc(3*(100vh-80px))]",
i === 4 && "top-[calc(4*(100vh-80px))]",
)}
/>
))}
{/* Animation container */}
<div
id="animation-container"
className="sticky top-0 h-[calc(100vh-80px)] w-full overflow-hidden"
>
{/* Animation goes here */}
</div>
</section>
);
};
You might've noticed the cn
function I'm using to conditionally apply and merge classes. It's a handy utility that I picked up from shadcn/ui, which uses clsx
and tailwind-merge
under the hood. Here's the code for it:
import clsx from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
sticky
instead of fixed
for the animation container?
Why use Because we want the user to be able to scroll past the animation and see the
rest of the page. A sticky
element will only stick to the top the viewport
until it reaches the end of its parent container, at which point it will
behave like a relative
element.
Scroll Snapping
We'll use CSS's scroll snap feature to enable scroll snapping. We only want it enabled until the user reaches the end of the parent container. To achieve this, we can put these styles in a class and toggle it on and off based on the scroll progress.
.scroll-snap-enabled {
scroll-snap-type: y mandatory;
scroll-padding-top: 80px; /* Considering the sticky header */
scroll-snap-stop: always;
}
This is the function we'll use to toggle the above class on and off. We'll add the class to both the html
and body
elements, just to be safe. I'll explain where we'll call this function later.
function toggleScrollSnap(on) {
const htmlElement = document.querySelector("html");
const bodyElement = document.querySelector("body");
const className = "scroll-snap-enabled";
[htmlElement, bodyElement].forEach((el) => {
if (on) el.classList.add(className);
else el.classList.remove(className);
});
}
Maintaining the aspect ratio of the image container
Since the main content of the animation container is an image, we want to maintain its aspect ratio.
This image container actually holds a bunch of layers that are absolutely positioned. We also want it to take up as much space as possible without overflowing the viewport, and we want it to be centered both vertically and horizontally. This can't be achieved with CSS alone, so we'll have to do it manually using JavaScript:
const HeroSection = () => {
const layersContainerRef = useRef(null);
useEffect(() => {
if (!layersContainerRef.current) return;
function handleResize() {
const aspectRatio = 1236 / 891; // ~1.387
const layersContainer = layersContainerRef.current;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight - 80; // Considering the header height
const heightBasedWidth = Math.floor(viewportWidth / aspectRatio);
const widthBasedHeight = Math.floor(viewportHeight * aspectRatio);
const newWidth =
heightBasedWidth > viewportHeight ? widthBasedHeight : viewportWidth;
const newHeight =
heightBasedWidth > viewportHeight ? viewportHeight : heightBasedWidth;
if (
newWidth !== layersContainer.offsetWidth ||
newHeight !== layersContainer.offsetHeight
) {
layersContainer.style.width = `${newWidth}px`;
layersContainer.style.height = `${newHeight}px`;
}
}
handleResize(); // Run once on mount
window.addEventListener("resize", handleResize);
// Cleanup on unmount
return () => {
window.removeEventListener("resize", handleResize);
};
}, [layersContainerRef]);
return (
<section id="parent-container" {/* ... */}>
{/* ... */}
<div id="animation-container" {/* ... */}>
{/* Image container */}
<div
ref={layersContainerRef}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
{/* ... */}
</div>
</div>
</section>
);
};
The code above will listen to the resize
event and update the width and height of the image container accordingly.
To center the container both vertically and horizontally, we've added tailwind classes to set the top
and left
positions to 50%
, and translate the container by -50%
in both directions.
The Animation
We'll be using Framer Motion to build the animation. It's a great library that makes it easy to create complex animations.
There were a lot of ways to approach this, and after a lot of trial and error, I decided to go with the following approach:
- We'll use the
useScroll
hook from Framer Motion to getscrollYProgress
, which is a value between0
and1
that represents the scroll progress of the page or a target element (in our case, the parent container). We'll listen to changes to this value usinguseMotionValueEvent
. - We want to trigger the animation as soon as the user starts scrolling. To achieve this, we'll need to know the scroll direction. We can get it by comparing the current
scrollYProgress
value with the previous one that we'll store in auseRef
hook. Once we have the scroll direction, we can use it to determine which slide index the user is going to next. - For all the elements that we want to animate, we'll create motion variants for each slide index, and use the
activeIndex
state to determine which variant to animate to.
import { useRef, useState, useEffect } from "react";
import { useMotionValueEvent, useScroll } from "framer-motion";
const HeroSection = () => {
const parentContainerRef = useRef(null);
const { scrollYProgress } = useScroll({
target: parentContainerRef,
offset: ["-80px start", "end end"], // Considering the sticky header
});
const previousScrollYProgress = useRef(0);
const [activeIndex, setActiveIndex] = useState(0);
useMotionValueEvent(scrollYProgress, "change", (progress) => {
const steps = [0, 0.25, 0.5, 0.75, 1]; // The scroll progress at which each of the 5 slides start/end
const offset = 0.01; // A small offset to prevent the activeIndex from changing too early
const scrollDirection =
progress > previousScrollYProgress.current ? "down" : "up";
let index;
if (scrollDirection === "down") {
if (progress > steps[3] + offset) index = 4;
else if (progress > steps[2] + offset) index = 3;
else if (progress > steps[1] + offset) index = 2;
else if (progress > steps[0] + offset) index = 1;
else index = 0;
} else {
if (progress < steps[1] - offset) index = 0;
else if (progress < steps[2] - offset) index = 1;
else if (progress < steps[3] - offset) index = 2;
else if (progress < steps[4] - offset) index = 3;
else index = 4;
}
if (activeIndex !== index) setActiveIndex(index);
toggleScrollSnap(progress !== 1);
previousScrollYProgress.current = progress;
});
useEffect(() => {
toggleScrollSnap(true);
return () => toggleScrollSnap(false);
}, []);
return (
<section
ref={parentContainerRef}
id="parent-container"
// ...
>
{/* ... */}
</section>
);
};
In the code above, we've called the toggleScrollSnap
function in two places:
- In the
useEffect
hook to enable scroll snapping when the component mounts, and disable it when it unmounts. - In the
useMotionValueEvent
hook to disable scroll snapping when the user reaches the end of the parent container.
Animating with variants
I won't go into too much detail about every part of the animation, but I'll explain how the main image is being animated. Here's the code:
<motion.div
ref={layerscontainer}
className="pointer-events-none absolute left-1/2 top-1/2"
variants={{
0: { x: "-50%", y: "-50%", scale: 1 },
1: { x: "-50%", y: "-90%", scale: 2 },
2: { x: "35%", y: "-45%", scale: 2 },
3: { x: "-50%", y: "16%", scale: 2 },
4: { x: "-130%", y: "-45%", scale: 2 },
}}
initial="0" // The initial variant on mount
animate={String(activeIndex)} // The variant to animate to
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
>
{/* image here */}
</motion.div>
We've converted the image container from earlier into a motion.div
element so that we can animate it. We're using the variants
prop to define the position and scale of the element for each slide index, and the animate
prop to animate the element to the variant corresponding to the activeIndex
state.
Here's what the output looks like:
Skipping the animation when the user scrolls too fast
We want to end the animation transition immediately if the user scrolls to the next slide before the transition has finished. To do this, we can use useAnimationControls
, like so:
const controls = useAnimationControls();
const isAnimating = useRef(false);
useEffect(() => {
if (isAnimating.current) {
controls.stop();
controls.set(String(activeIndex));
} else {
controls.start(String(activeIndex));
isAnimating.current = true;
setTimeout(() => {
isAnimating.current = false;
}, 600); // The duration of the transition
}
}, [activeIndex]);
We'll pass the controls
object to the animate
prop instead of the activeIndex
state.
<motion.div
// ...
animate={controls}
>
{/* ... */}
</motion.div>
This will ensure that the animation doesn't stutter when the user scrolls too fast.
Conclusion
That's it! I hope this article helped you understand the basics of how to build a scroll-based animation. If you have any questions or feedback, feel free to reach out to me through any of the following channels: