A reusable AnimatedSection component that animates content in and out of the viewport based on scroll direction and position, built with Framer Motion and React hooks.

Animations can breathe life into your web applications, enhancing the user experience and making your interfaces more engaging. In this article, we’ll explore a React component called AnimatedSection that leverages Framer Motion to create dynamic scroll-based animations. This component is fully customizable, making it a great addition to your projects.
The AnimatedSection component animates its content as it comes into or leaves the viewport based on the user’s scroll direction and position. With the help of Framer Motion and React hooks, it dynamically adjusts its animations to create smooth transitions.
Some features include:
Let’s break the code into smaller parts and explain each section for better understanding.
import React, { useEffect, useState, useRef, PropsWithChildren } from "react";
import { motion, useAnimation, Variants } from "framer-motion";
interface Props {
className?: string;
threshold?: number;
}Here, we import necessary hooks from React (useEffect, useState, useRef) and Framer Motion (motion, useAnimation, Variants). We define the Props interface to allow optional className and threshold props.
const AnimatedSection: React.FC<PropsWithChildren<Props>> = (props) => {
const { children, className, threshold = 0.25 } = props;
const controls = useAnimation();
const sectionRef = useRef<HTMLDivElement | null>(null);
const [direction, setDirection] = useState<"up" | "down" | null>(null);
const [position, setPosition] = useState<"top" | "bottom" | null>(null);
const [isInView, setInView] = useState<boolean>(false);Here, we define the skeleton of the component and initialize several state variables:
const handleScroll = () => {
const section = sectionRef.current;
if (!section) return;
const rect = section.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const sectionHeight = section.clientHeight;
const topThreshold = threshold * viewportHeight;
const bottomThreshold = (1 - threshold) * viewportHeight;
if (rect.y + sectionHeight < topThreshold) {
setPosition("top");
} else if (rect.y > bottomThreshold) {
setPosition("bottom");
} else {
setPosition(null); // Fully in view
}
if (rect.y + sectionHeight > 0 && rect.y < viewportHeight) {
setInView(true);
} else setInView(false);
};This function uses getBoundingClientRect() to calculate the section’s position relative to the viewport. It updates the position and isInView states based on whether the section is:
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);This useEffect attaches the handleScroll function to the scroll event and ensures the event listener is removed when the component unmounts.
useEffect(() => {
let lastScrollY = window.scrollY;
const handleScroll = () => {
const currentScrollY = window.scrollY;
if (currentScrollY > lastScrollY) {
setDirection("down");
} else if (currentScrollY < lastScrollY) {
setDirection("up");
}
lastScrollY = currentScrollY > 0 ? currentScrollY : 0; // Avoid negative scroll values
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);This effect tracks the user’s scroll direction by comparing the current scroll position with the last scroll position.
useEffect(() => {
if (direction === "down" && position === "top") {
controls.start("scrollingOutTop");
} else if (direction === "up" && position === "bottom") {
controls.start("scrollingOutBottom");
} else {
if (isInView) controls.start("visible");
}
}, [direction, position, isInView, controls]);This effect triggers the appropriate animation based on the scroll direction, position, and whether the section is isInView.
const variants: Variants = {
scrollingOutTop: { opacity: 0, y: -100, transition: { duration: 0.5 } },
scrollingOutBottom: { opacity: 0, y: 100, transition: { duration: 0.5 } },
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
};The variants object defines three animation states:
return (
<motion.div
ref={sectionRef}
animate={controls}
initial="visible"
variants={variants}
className={className}
>
{children}
</motion.div>
);
};
export default AnimatedSection;Finally, the component renders a motion.div from Framer Motion. The animate prop dynamically applies the animation state based on the controls object.
import React, { useEffect, useState, useRef, PropsWithChildren } from "react";
import { motion, useAnimation, Variants } from "framer-motion";
interface Props {
className?: string;
threshold?: number;
}
const AnimatedSection: React.FC<PropsWithChildren<Props>> = (props) => {
const { children, className, threshold = 0.25 } = props;
const controls = useAnimation();
const sectionRef = useRef<HTMLDivElement | null>(null);
const [direction, setDirection] = useState<"up" | "down" | null>(null);
const [position, setPosition] = useState<"top" | "bottom" | null>(null);
const [isInView, setInView] = useState<boolean>(false);
const handleScroll = () => {
const section = sectionRef.current;
if (!section) return;
const rect = section.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const sectionHeight = section.clientHeight;
const topThreshold = threshold * viewportHeight;
const bottomThreshold = (1 - threshold) * viewportHeight;
if (rect.y + sectionHeight < topThreshold) {
setPosition("top");
} else if (rect.y > bottomThreshold) {
setPosition("bottom");
} else {
setPosition(null); // Fully in view
}
if (rect.y + sectionHeight > 0 && rect.y < viewportHeight) {
setInView(true);
} else setInView(false);
};
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
useEffect(() => {
let lastScrollY = window.scrollY;
const handleScroll = () => {
const currentScrollY = window.scrollY;
if (currentScrollY > lastScrollY) {
setDirection("down");
} else if (currentScrollY < lastScrollY) {
setDirection("up");
}
lastScrollY = currentScrollY > 0 ? currentScrollY : 0; // Avoid negative scroll values
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => {
if (direction === "down" && position === "top") {
controls.start("scrollingOutTop");
} else if (direction === "up" && position === "bottom") {
controls.start("scrollingOutBottom");
} else {
if (isInView) controls.start("visible");
}
}, [direction, position, isInView, controls]);
const variants: Variants = {
scrollingOutTop: { opacity: 0, y: -100, transition: { duration: 0.5 } },
scrollingOutBottom: { opacity: 0, y: 100, transition: { duration: 0.5 } },
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
};
return (
<motion.div
ref={sectionRef}
animate={controls}
initial="visible"
variants={variants}
className={className}
>
{children}
</motion.div>
);
};
export default AnimatedSection;Here’s how you can integrate the AnimatedSection component into your application:
import AnimatedSection from "./AnimatedSection";
const App = () => {
return (
<div>
<AnimatedSection threshold={0.2} className="my-16 p-8 bg-gray-200 rounded-md">
<h1 className="text-xl font-bold">Hello, World!</h1>
<p>Scroll to see the animations in action!</p>
</AnimatedSection>
<div className="h-[200vh] bg-gradient-to-b from-white to-gray-300"></div>
</div>
);
};
export default App;The AnimatedSection component is a powerful tool for adding dynamic, scroll-based animations to your React projects. With its modular design and customizability, it’s an excellent choice for enhancing your UI. Start using it today to captivate your users with smooth, interactive animations!