✨ What We're Building
The "Text Repetition on Scroll" effect stacks multiple layers of the same text element on top of each other, each moving at a slightly different speed as the page scrolls. The result is a beautiful depth effect reminiscent of parallax.
You can see the original effect on Framer University.
⚙️ Tech Stack
- React
- Tailwind CSS ( you can use Raw CSS too, nothing too special is being used!)
- Framer motion
🧩 Step 1: Project Setup
I will assume you have a Next.js app ready. If not, run:
npx create-next-app@latest my-text-effect-app
cd my-text-effect-app
# select yes for Tailwind (only if you want to use)
Lets also install framer motion
npm install motion
Just to make sure we are on the same start, I will be using the Archivo Black font which is the same font used in Framer component as well.
To use this font in next.js simply add it from next/font/google
import { Archivo_Black } from "next/font/google";
const archivoBlack = Archivo_Black({
variable: "--font-archivo-black",
weight: "400",
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${archivoBlack.variable} antialiased`}
>
{children}
</body>
</html>
);
}
🏗️ Step 2: Create the TextRepetition Component
Here's the core idea:
- We'll map over an array of speed factors to render multiple layers of text.
- Each layer uses Framer Motion's useScroll and useTransform to map scroll position to Y translation.
Let's break it down. Starting with the base setup of the component
'use client'
interface TextRepetitionProps {
text: string;
}
export function TextRepition({ text }: TextRepetitionProps) {
return (
<div className="relative overflow-visible">
...
</div>
);
}
Now we have a good starting point, to start with we will have two things:
- The scroll value of the container
- The speedfactor at which we want each text layer to move.
Let me show you.
import { useScroll } from "motion/react";
export function TextRepition({ text }: TextRepetitionProps) {
const { scrollY } = useScroll();
const speedFactors = [
1.1, // top
1.07,
1.03,
1.0, // static main text
0.98,
0.96,
0.945, // bottom
];
return (
<div className="relative overflow-visible">
{speedFactors.map((_, i) => (
<Layer
key={i}
scrollY={scrollY}
text={text}
speedFactors={speedFactors}
index={i}
/>
))}
</div>
);
}
We want the top most text to move fastest, and the bottom most to move the slowest and this way we could get a disperesed effect.
Now lets look at the Layer component
import { motion } from "motion/react";
interface LayerProps {
scrollY: MotionValue<number>;
text: string;
index: number;
speedFactors: number[];
}
function Layer({ speedFactors, scrollY, text, index }: LayerProps) {
return (
<motion.p
className="h-[0.78em] px-4 uppercase w-full text-[15vw] text-center font-black bg-black tracking-tighter leading-[0.78] lg:leading-[0.75]"
>
{text}
</motion.p>
);
}
Currently we are just styling the text layers to have a black background and have a responsive sizes based on window size.
Now we need the transform values of how much we wanna translate each layer and setting up their positions
// In Layer component
const currSpeedFactor = speedFactors[index]; // speed factor of the current layer
const isMain = currSpeedFactor === 1.0;
const middleIndex = Math.floor(speedFactors.length / 2); // 3 in our case
const point = middleIndex - index; // 3, 2, 1, 0, -1, -2, -3
const translateY = useTransform(
scrollY,
(value) => (1 - currSpeedFactor) * value
);
return (
<motion.p
style={{
zIndex:
point === 0 ? middleIndex + 1 : middleIndex + 1 - Math.abs(point),
// this way we will have zIndex of 1, 2, 3, 4, 3, 2, 1
translateY: isMain ? 0 : translateY,
position: isMain ? "relative" : "absolute",
inset: isMain ? "auto" : 0,
}}
className="h-[0.78em] px-4 uppercase w-full text-[15vw] text-center font-black bg-black tracking-tighter leading-[0.78] lg:leading-[0.75]"
>
{text}
</motion.p>
);
The final component would look like this:
"use client";
import { motion, useScroll, useTransform, MotionValue } from "motion/react";
interface TextRepetitionProps {
text: string;
}
export function TextRepition({ text }: TextRepetitionProps) {
const { scrollY } = useScroll();
const speedFactors = [
1.1, // top
1.07,
1.03,
1.0, // static main text
0.98,
0.96,
0.945, // bottom
];
return (
<div className="relative overflow-visible">
{speedFactors.map((_, i) => (
<Layer
key={i}
scrollY={scrollY}
text={text}
speedFactors={speedFactors}
index={i}
/>
))}
</div>
);
}
interface LayerProps {
scrollY: MotionValue<number>;
text: string;
index: number;
speedFactors: number[];
}
function Layer({ speedFactors, scrollY, text, index }: LayerProps) {
const currSpeedFactor = speedFactors[index];
const middleIndex = Math.floor(speedFactors.length / 2);
const point = middleIndex - index;
const isMain = currSpeedFactor === 1.0;
const translateY = useTransform(
scrollY,
(value) => (1 - currSpeedFactor) * value
);
return (
<motion.p
style={{
zIndex:
point === 0 ? middleIndex + 1 : middleIndex + 1 - Math.abs(point),
translateY: isMain ? 0 : translateY,
position: isMain ? "relative" : "absolute",
inset: isMain ? "auto" : 0,
}}
className="h-[0.78em] px-4 uppercase w-full text-[15vw] text-center font-black bg-black tracking-tighter leading-[0.78] lg:leading-[0.75]"
>
{text}
</motion.p>
);
}
🧪 Step 3: Use the Component in Your Page
Example usage in app/page.tsx
import { TextRepetition } from "@/components/TextRepetition";
export default function Home() {
return (
<main className="w-full bg-black text-white flex items-center justify-center h-[250vh]">
<TextRepetition text="Soulmate" />
</main>
);
}
And the result 🎉
✍️ Wrapping Up
I hope this step-by-step guide demystifies how Framer Motion makes advanced scroll effects trivial yet performant. With React's declarative style and Tailwind's tight utility classes, you can design with the same finesse as no-code tools - but with developer freedom.