Framer Motion: Vertical Infinite Animation With Dynamic Content
Hey guys! Ever wanted to create a slick, infinite scrolling animation with a twist? Imagine a vertical list where each item smoothly slides into view, but here's the cool part: as an item passes through a specific masked area, the content actually changes. Sounds awesome, right? Well, you're in the right place! This guide dives deep into how to achieve this eye-catching effect using React, Tailwind CSS, and the magic of Framer Motion.
This effect is super versatile and can be used in a ton of different ways. Think animated testimonials, feature lists that dynamically update, or even a unique way to display portfolio items. The possibilities are truly endless, and the best part is, it's not as complicated as it might seem. We'll break down the code step-by-step, so even if you're relatively new to these technologies, you'll be able to follow along and build your own version of this animation. So, let’s jump right in and explore how to bring this creative vision to life!
Setting Up Your Project
Before we get to the fun animation stuff, let's make sure we have a solid foundation. This means setting up our React project with Tailwind CSS for styling and Framer Motion for the smooth animations. If you already have a project with these libraries installed, feel free to skip ahead. But for those who are starting fresh, here’s a quick rundown:
-
Create a New React App: If you don't already have a React project, the easiest way to get started is using Create React App. Open your terminal and run:
npx create-react-app my-animation-app cd my-animation-app
This will scaffold a new React project for you.
-
Install Tailwind CSS: Next, let's add Tailwind CSS for styling. Tailwind CSS is a utility-first CSS framework that makes it super easy to style your components. Follow these steps:
-
Install the necessary packages:
npm install -D tailwindcss postcss autoprefixer
-
Generate Tailwind configuration files:
npx tailwindcss init -p
-
Configure Tailwind to remove unused styles in production. Open
tailwind.config.js
and modify thecontent
array:/** @type {import('tailwindcss').Config} */ module.exports = { content: [ "./src/**/*.{js,jsx,ts,tsx}", "./public/index.html", ], theme: { extend: {}, }, plugins: [], }
-
Add the Tailwind directives to your main CSS file (
src/index.css
):@tailwind base; @tailwind components; @tailwind utilities;
-
Finally, import the
index.css
file in yoursrc/index.js
file:import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; // Import Tailwind styles import App from './App'; import reportWebVitals from './reportWebVitals'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <App /> </React.StrictMode> ); reportWebVitals();
-
-
Install Framer Motion: Now, let's install Framer Motion, the library that will power our animations:
npm install framer-motion
With these steps completed, you've got your React project geared up with Tailwind CSS for styling and Framer Motion for animations. This setup is the bedrock for our infinite scrolling animation with content masking. In the next section, we’ll delve into the heart of the matter: crafting the actual animation.
Building the Basic Vertical Animation
Alright, with our project set up, it's time to dive into the core of our mission: creating the vertical infinite animation. This involves setting up the basic structure of our list, defining the animation using Framer Motion, and ensuring the list loops seamlessly. Here's how we'll tackle it:
-
Creating the List Structure: First, we need to create the basic structure for our vertical list. This will involve creating an array of data items and rendering them as list elements. For simplicity, let’s start with a simple array of strings. Think of these strings as the initial content for each of our list items. We’ll also use Tailwind CSS to style the list for a clean, vertical layout.
// src/App.js import React from 'react'; import { motion } from 'framer-motion'; const items = [ 'Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5' ]; function App() { return ( <div className="flex items-center justify-center h-screen bg-gray-100"> <div className="overflow-hidden h-96 w-64"> <motion.ul className="py-4"> {items.map((item, index) => ( <motion.li key={index} className="h-24 flex items-center justify-center bg-white border border-gray-300 rounded-md mb-2 last:mb-0 text-lg font-semibold" > {item} </motion.li> ))} </motion.ul> </div> </div> ); } export default App;
In this snippet:
- We import
motion
fromframer-motion
, which is how we'll animate our components. - We define a simple
items
array with some placeholder text. - We render these items within a
motion.ul
(a Framer Motion unordered list). Each item is amotion.li
(a Framer Motion list item). - We use Tailwind CSS classes for styling:
flex
,items-center
,justify-center
,h-screen
,bg-gray-100
for the main container;overflow-hidden
,h-96
,w-64
for the list container; and various classes for the list items to give them a nice visual appearance.
- We import
-
Animating the List with Framer Motion: Now comes the fun part – adding the animation! We'll use Framer Motion's
animate
prop to create the vertical scrolling effect. The idea is to animate they
position of themotion.ul
to create the illusion of an infinite scroll. To achieve this, we need to calculate the total height of the list and animate it moving upwards, then seamlessly loop it back to the start.// src/App.js import React, { useRef, useEffect } from 'react'; import { motion, useAnimation } from 'framer-motion'; const items = [ 'Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5' ]; function App() { const listRef = useRef(null); const controls = useAnimation(); useEffect(() => { const listHeight = listRef.current.scrollHeight; const containerHeight = listRef.current.clientHeight; const animationDistance = listHeight - containerHeight; controls.start({ y: -animationDistance, transition: { duration: 10, // Adjust the duration for desired speed ease: "linear", repeat: Infinity, }, }); }, [controls]); return ( <div className="flex items-center justify-center h-screen bg-gray-100"> <div className="overflow-hidden h-96 w-64"> <motion.ul ref={listRef} className="py-4" style={{ y: 0 }} // Initial y position animate={controls} > {items.map((item, index) => ( <motion.li key={index} className="h-24 flex items-center justify-center bg-white border border-gray-300 rounded-md mb-2 last:mb-0 text-lg font-semibold" > {item} </motion.li> ))} </motion.ul> </div> </div> ); } export default App;
Here’s a breakdown of the changes:
- We import
useRef
anduseEffect
from React, anduseAnimation
from Framer Motion. - We create a
listRef
usinguseRef
to get a reference to themotion.ul
element. This allows us to measure its height. - We use
useAnimation
to create animation controls. This gives us more fine-grained control over the animation. - Inside the
useEffect
hook:- We calculate the
listHeight
(the total height of the list content) andcontainerHeight
(the visible height of the list container). - We determine
animationDistance
, which is the amount the list needs to move to create the infinite scroll effect. - We use
controls.start
to start the animation. We animate they
property to-animationDistance
. Thetransition
prop is crucial:duration
controls the speed of the animation. Adjust this value to your liking.ease: "linear"
ensures a constant scrolling speed.repeat: Infinity
makes the animation loop endlessly.
- We calculate the
- We set the initial
y
position to0
using thestyle
prop onmotion.ul
. - We pass the
controls
to theanimate
prop ofmotion.ul
, telling Framer Motion to use these controls for the animation.
- We import
-
Ensuring Seamless Looping: The
repeat: Infinity
in our animation transition is key to the infinite scrolling effect. Framer Motion handles the looping seamlessly, so we don't have to worry about manually resetting the animation. The list will continuously scroll upwards, creating the illusion of an endless list.
With these steps, you’ve created a basic vertical infinite animation using Framer Motion. The list items scroll smoothly and continuously, providing a foundation for the next step: adding the content masking effect. In the following section, we'll explore how to create a mask that triggers content changes as items pass through it. This is where the animation really starts to get interesting!
Implementing the Content Mask
Now for the really cool part: implementing the content mask! This is where we add the logic to change the content of a list item as it passes through a designated masked area. We'll achieve this by creating a mask element, detecting when an item intersects with it, and updating the item's content accordingly. This will involve a combination of styling with Tailwind CSS, animation control with Framer Motion, and some JavaScript logic to manage the content updates. Here’s how we'll approach it:
-
Creating the Mask Element: First, we need to create a visual mask element. This will be a
div
that sits in the middle of our list container and acts as the trigger for content changes. We'll use Tailwind CSS to style this mask, giving it a distinctive appearance and ensuring it's positioned correctly.// src/App.js import React, { useRef, useEffect, useState } from 'react'; import { motion, useAnimation } from 'framer-motion'; const items = [ { id: 1, content: 'Item 1', maskedContent: 'Masked Content 1' }, { id: 2, content: 'Item 2', maskedContent: 'Masked Content 2' }, { id: 3, content: 'Item 3', maskedContent: 'Masked Content 3' }, { id: 4, content: 'Item 4', maskedContent: 'Masked Content 4' }, { id: 5, content: 'Item 5', maskedContent: 'Masked Content 5' }, ]; function App() { const listRef = useRef(null); const controls = useAnimation(); const [maskedItem, setMaskedItem] = useState(null); useEffect(() => { const listHeight = listRef.current.scrollHeight; const containerHeight = listRef.current.clientHeight; const animationDistance = listHeight - containerHeight; controls.start({ y: -animationDistance, transition: { duration: 10, ease: "linear", repeat: Infinity, }, }); }, [controls]); return ( <div className="flex items-center justify-center h-screen bg-gray-100"> <div className="overflow-hidden h-96 w-64 relative"> <div className="absolute top-1/2 left-0 w-full h-24 transform -translate-y-1/2 bg-yellow-200 bg-opacity-50 pointer-events-none" /> <motion.ul ref={listRef} className="py-4" style={{ y: 0 }} animate={controls} > {items.map((item) => ( <motion.li key={item.id} className="h-24 flex items-center justify-center bg-white border border-gray-300 rounded-md mb-2 last:mb-0 text-lg font-semibold" > {item.content} </motion.li> ))} </motion.ul> </div> </div> ); } export default App;
Here’s what we’ve added:
- We've updated the
items
array to be an array of objects, each with anid
,content
, andmaskedContent
. ThemaskedContent
will be the new content displayed when the item passes through the mask. - We've added a
useState
hook formaskedItem
, which will hold the currently masked item. We'll use this later to trigger content updates. - We've added a
div
with the following classes:absolute
,top-1/2
,left-0
,w-full
,h-24
,transform
,-translate-y-1/2
,bg-yellow-200
,bg-opacity-50
,pointer-events-none
. Thisdiv
acts as our mask:absolute
andrelative
(on the parentdiv
) position the mask within the container.top-1/2
andtransform -translate-y-1/2
vertically center the mask.w-full
makes the mask span the full width of the container.h-24
sets the height of the mask.bg-yellow-200 bg-opacity-50
gives the mask a semi-transparent yellow background so we can see it.pointer-events-none
ensures the mask doesn't interfere with any pointer events.
- We've updated the
-
Detecting Item Intersection with the Mask: Now, we need to detect when a list item intersects with the mask. We can achieve this by using the
useIntersection
hook from Framer Motion or by manually calculating the intersection using thegetBoundingClientRect
method. For this example, we'll use a manual approach for clarity.// src/App.js import React, { useRef, useEffect, useState } from 'react'; import { motion, useAnimation } from 'framer-motion'; const items = [ { id: 1, content: 'Item 1', maskedContent: 'Masked Content 1' }, { id: 2, content: 'Item 2', maskedContent: 'Masked Content 2' }, { id: 3, content: 'Item 3', maskedContent: 'Masked Content 3' }, { id: 4, content: 'Item 4', maskedContent: 'Masked Content 4' }, { id: 5, content: 'Item 5', maskedContent: 'Masked Content 5' }, ]; function App() { const listRef = useRef(null); const controls = useAnimation(); const [maskedItem, setMaskedItem] = useState(null); const maskRef = useRef(null); useEffect(() => { const listHeight = listRef.current.scrollHeight; const containerHeight = listRef.current.clientHeight; const animationDistance = listHeight - containerHeight; controls.start({ y: -animationDistance, transition: { duration: 10, ease: "linear", repeat: Infinity, }, }); const checkIntersections = () => { if (!listRef.current || !maskRef.current) return; const maskRect = maskRef.current.getBoundingClientRect(); const listItems = Array.from(listRef.current.children); listItems.forEach(item => { const itemRect = item.getBoundingClientRect(); const isIntersecting = !(maskRect.bottom < itemRect.top || maskRect.top > itemRect.bottom); if (isIntersecting) { setMaskedItem(item.dataset.id); } }); }; checkIntersections(); // Initial check const intervalId = setInterval(checkIntersections, 100); // Check every 100ms return () => clearInterval(intervalId); // Cleanup on unmount }, [controls]); return ( <div className="flex items-center justify-center h-screen bg-gray-100"> <div className="overflow-hidden h-96 w-64 relative"> <div ref={maskRef} className="absolute top-1/2 left-0 w-full h-24 transform -translate-y-1/2 bg-yellow-200 bg-opacity-50 pointer-events-none" /> <motion.ul ref={listRef} className="py-4" style={{ y: 0 }} animate={controls} > {items.map((item) => ( <motion.li key={item.id} data-id={item.id} // Add a data-id attribute className="h-24 flex items-center justify-center bg-white border border-gray-300 rounded-md mb-2 last:mb-0 text-lg font-semibold" > {item.content} </motion.li> ))} </motion.ul> </div> </div> ); } export default App;
Key updates:
- We've added a
maskRef
usinguseRef
to reference the mask element. - Inside the
useEffect
hook, we've added acheckIntersections
function:- It gets the bounding rectangles of the mask and each list item using
getBoundingClientRect
. - It checks for intersection by comparing the rectangles. If the mask and item rectangles overlap,
isIntersecting
will be true. - If an item is intersecting, we call
setMaskedItem
with the item'sid
. To make this work, we've added adata-id
attribute to eachmotion.li
.
- It gets the bounding rectangles of the mask and each list item using
- We call
checkIntersections
initially to check for any items already intersecting. - We set up an interval using
setInterval
to callcheckIntersections
every 100ms. This ensures we continuously check for intersections as the list scrolls. - We return a cleanup function from
useEffect
that clears the interval usingclearInterval
when the component unmounts. This prevents memory leaks. - We've added
ref={maskRef}
to the maskdiv
. - We've added
data-id={item.id}
to eachmotion.li
.
- We've added a
-
Updating Item Content Based on Intersection: Finally, we need to update the content of the list item when it's masked. We'll use the
maskedItem
state to determine which item's content should be changed. This involves conditionally rendering thecontent
ormaskedContent
based on whether the item'sid
matches themaskedItem
.// src/App.js import React, { useRef, useEffect, useState } from 'react'; import { motion, useAnimation } from 'framer-motion'; const items = [ { id: 1, content: 'Item 1', maskedContent: 'Masked Content 1' }, { id: 2, content: 'Item 2', maskedContent: 'Masked Content 2' }, { id: 3, content: 'Item 3', maskedContent: 'Masked Content 3' }, { id: 4, content: 'Item 4', maskedContent: 'Masked Content 4' }, { id: 5, content: 'Item 5', maskedContent: 'Masked Content 5' }, ]; function App() { const listRef = useRef(null); const controls = useAnimation(); const [maskedItem, setMaskedItem] = useState(null); const maskRef = useRef(null); useEffect(() => { const listHeight = listRef.current.scrollHeight; const containerHeight = listRef.current.clientHeight; const animationDistance = listHeight - containerHeight; controls.start({ y: -animationDistance, transition: { duration: 10, ease: "linear", repeat: Infinity, }, }); const checkIntersections = () => { if (!listRef.current || !maskRef.current) return; const maskRect = maskRef.current.getBoundingClientRect(); const listItems = Array.from(listRef.current.children); listItems.forEach(item => { const itemRect = item.getBoundingClientRect(); const isIntersecting = !(maskRect.bottom < itemRect.top || maskRect.top > itemRect.bottom); if (isIntersecting) { setMaskedItem(item.dataset.id); } }); }; checkIntersections(); const intervalId = setInterval(checkIntersections, 100); return () => { clearInterval(intervalId); }; }, [controls]); return ( <div className="flex items-center justify-center h-screen bg-gray-100"> <div className="overflow-hidden h-96 w-64 relative"> <div ref={maskRef} className="absolute top-1/2 left-0 w-full h-24 transform -translate-y-1/2 bg-yellow-200 bg-opacity-50 pointer-events-none" /> <motion.ul ref={listRef} className="py-4" style={{ y: 0 }} animate={controls} > {items.map((item) => ( <motion.li key={item.id} data-id={item.id} className="h-24 flex items-center justify-center bg-white border border-gray-300 rounded-md mb-2 last:mb-0 text-lg font-semibold" > {maskedItem === String(item.id) ? item.maskedContent : item.content} </motion.li> ))} </motion.ul> </div> </div> ); } export default App;
The crucial change is in the rendering of the
motion.li
:{maskedItem === String(item.id) ? item.maskedContent : item.content}
This line uses a ternary operator to conditionally render the content. If the
maskedItem
state matches the item'sid
(converted to a string sincedataset.id
is a string), it rendersitem.maskedContent
; otherwise, it rendersitem.content
.
With these steps, you've successfully implemented the content mask! As list items scroll through the yellow mask area, their content will dynamically change, creating a visually engaging effect. This technique can be used to highlight information, reveal hidden details, or add a touch of interactivity to your UI.
Polishing and Enhancements
We've built a solid foundation for our vertical infinite animation with content masking. But, like any great creation, there's always room for polishing and enhancements. This is where we can add those extra touches that elevate the animation from good to outstanding. Let’s explore some ideas to make our animation even more captivating:
-
Adding Transitions: Right now, the content change is immediate. Adding a subtle transition can make the switch feel smoother and more polished. We can use Framer Motion's
AnimatePresence
component andmotion
components to animate the content change.// src/App.js import React, { useRef, useEffect, useState } from 'react'; import { motion, useAnimation, AnimatePresence } from 'framer-motion'; const items = [ { id: 1, content: 'Item 1', maskedContent: 'Masked Content 1' }, { id: 2, content: 'Item 2', maskedContent: 'Masked Content 2' }, { id: 3, content: 'Item 3', maskedContent: 'Masked Content 3' }, { id: 4, content: 'Item 4', maskedContent: 'Masked Content 4' }, { id: 5, content: 'Item 5', maskedContent: 'Masked Content 5' }, ]; const contentVariants = { initial: { opacity: 0, y: 10 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -10 }, }; function App() { const listRef = useRef(null); const controls = useAnimation(); const [maskedItem, setMaskedItem] = useState(null); const maskRef = useRef(null); useEffect(() => { const listHeight = listRef.current.scrollHeight; const containerHeight = listRef.current.clientHeight; const animationDistance = listHeight - containerHeight; controls.start({ y: -animationDistance, transition: { duration: 10, ease: "linear", repeat: Infinity, }, }); const checkIntersections = () => { if (!listRef.current || !maskRef.current) return; const maskRect = maskRef.current.getBoundingClientRect(); const listItems = Array.from(listRef.current.children); listItems.forEach(item => { const itemRect = item.getBoundingClientRect(); const isIntersecting = !(maskRect.bottom < itemRect.top || maskRect.top > itemRect.bottom); if (isIntersecting) { setMaskedItem(item.dataset.id); } }); }; checkIntersections(); const intervalId = setInterval(checkIntersections, 100); return () => { clearInterval(intervalId); }; }, [controls]); return ( <div className="flex items-center justify-center h-screen bg-gray-100"> <div className="overflow-hidden h-96 w-64 relative"> <div ref={maskRef} className="absolute top-1/2 left-0 w-full h-24 transform -translate-y-1/2 bg-yellow-200 bg-opacity-50 pointer-events-none" /> <motion.ul ref={listRef} className="py-4" style={{ y: 0 }} animate={controls} > {items.map((item) => ( <motion.li key={item.id} data-id={item.id} className="h-24 flex items-center justify-center bg-white border border-gray-300 rounded-md mb-2 last:mb-0 text-lg font-semibold" > <AnimatePresence mode="wait" initial={false}> <motion.span key={maskedItem === String(item.id) ? item.maskedContent : item.content} variants={contentVariants} initial="initial" animate="animate" exit="exit" className="absolute" transition={{ duration: 0.2 }} > {maskedItem === String(item.id) ? item.maskedContent : item.content} </motion.span> </AnimatePresence> </motion.li> ))} </motion.ul> </div> </div> ); } export default App;
Here’s what we’ve done:
- We've imported
AnimatePresence
fromframer-motion
. - We've defined
contentVariants
for the animation of the content:initial
: The initial state (opacity 0, moved down 10 pixels).animate
: The animated state (opacity 1, moved to its original position).exit
: The exit state (opacity 0, moved up 10 pixels).
- We've wrapped the content inside
motion.span
withinAnimatePresence
:- We've given
motion.span
a uniquekey
based on the content, soAnimatePresence
can track when the content changes. - We've applied the
contentVariants
using thevariants
prop. - We've set
initial="initial"
,animate="animate"
, andexit="exit"
to trigger the animations. - We've added
className="absolute"
tomotion.span
to position the content absolutely within the list item. - We've set
transition={{ duration: 0.2 }}
to control the speed of the transition.
- We've given
- We've imported
-
Dynamic Mask Styling: Instead of a static yellow mask, we could make the mask itself more dynamic. For example, we could animate the mask's color or size as items pass through it. This can add an extra layer of visual feedback and make the animation more engaging.
// src/App.js // Inside the App component const [maskActive, setMaskActive] = useState(false); useEffect(() => { // ... existing useEffect code ... const checkIntersections = () => { if (!listRef.current || !maskRef.current) return; const maskRect = maskRef.current.getBoundingClientRect(); const listItems = Array.from(listRef.current.children); let intersecting = false; listItems.forEach(item => { const itemRect = item.getBoundingClientRect(); const isIntersecting = !(maskRect.bottom < itemRect.top || maskRect.top > itemRect.bottom); if (isIntersecting) { setMaskedItem(item.dataset.id); intersecting = true; } }); setMaskActive(intersecting); }; // ... rest of useEffect code ... }, [controls]); return ( // ... rest of the component ... <div className="overflow-hidden h-96 w-64 relative"> <motion.div ref={maskRef} className="absolute top-1/2 left-0 w-full h-24 transform -translate-y-1/2 bg-yellow-200 bg-opacity-50 pointer-events-none" animate={{ backgroundColor: maskActive ? "rgba(255, 204, 0, 0.8)" : "rgba(255, 204, 0, 0.5)" }} transition={{ duration: 0.2 }} /> {/* ... rest of the component ... */} </div> // ... rest of the component ... );
Here’s what’s new:
- We added a
maskActive
state usinguseState
. - Inside
checkIntersections
, we now setmaskActive
totrue
if any item is intersecting andfalse
otherwise. - We changed the mask
div
to amotion.div
. - We added an
animate
prop tomotion.div
that animates thebackgroundColor
based on themaskActive
state. - We added a
transition
prop tomotion.div
to control the animation duration.
- We added a
-
Varying Content Updates: Instead of simply swapping content, we could implement more complex content updates. For example, we could fetch data from an API based on the item passing through the mask, or display different components based on the item type. This opens up a world of possibilities for dynamic and interactive content display.
-
Accessibility Considerations: It's crucial to ensure our animation is accessible to all users. This includes providing alternative ways to access the content, such as keyboard navigation and screen reader support. We should also be mindful of users with motion sensitivities and provide an option to disable the animation.
By implementing these polishing touches and enhancements, we can create a truly exceptional vertical infinite animation with content masking. Remember, the key is to experiment, iterate, and always strive to create the best possible user experience.
Conclusion
So there you have it, guys! We've journeyed through the process of creating a captivating vertical infinite animation with content masking using React, Tailwind CSS, and Framer Motion. We've covered everything from setting up your project to implementing the core animation logic, adding the content mask, and even polishing the final result with transitions and dynamic styling. This technique allows for a unique and engaging way to display information, reveal hidden details, and add a touch of interactivity to your user interface.
Remember, the possibilities are endless. This animation can be adapted and customized to fit a wide range of use cases, from dynamic testimonials and feature lists to interactive portfolio displays and more. The key is to experiment, be creative, and leverage the power of these technologies to bring your vision to life.
Framer Motion, in particular, is a fantastic tool for creating smooth and performant animations in React. Its declarative API and powerful features make it a joy to work with, and it's well worth exploring further for any React developer interested in adding motion and interactivity to their projects.
I hope this guide has been helpful and inspiring. Now it's your turn to take these concepts, build upon them, and create something amazing! Happy coding!