Recreate Framer's "Text Repetition on Scroll" using React and Framer Motion
Abdul R

Abdul R @abdulrdev

Location:
Pakistan
Joined:
Dec 26, 2023

Recreate Framer's "Text Repetition on Scroll" using React and Framer Motion

Publish Date: Jul 15
0 0

✨ 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)
Enter fullscreen mode Exit fullscreen mode

Lets also install framer motion

npm install motion
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🏗️ Step 2: Create the TextRepetition Component

Here's the core idea:

  1. We'll map over an array of speed factors to render multiple layers of text.
  2. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now we have a good starting point, to start with we will have two things:

  1. The scroll value of the container
  2. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🧪 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

And the result 🎉

Text repition effect preview

✍️ 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.

Comments 0 total

    Add comment