frontend.fyi

Awesome Storytelling By Animating Text With Framer Motion

Staggered animations definitely are one of those areas where Framer Motion is better at than CSS. Let's explore how we can create a reusable component animation texts together.

🧑‍💻 🎥
... Almost ready!

Adding subtle animations

Incorporating these subtle animations as users scroll through your website can significantly elevate your page’s storytelling. Consider text animations as an example. Instead of animating the entire title in one go, why not opt for a letter-by-letter approach?

Of course, there’s a fine balance between creating an engaging animation and ending up with a cluttered website. In today’s article, I’ll guide you through creating these staggered animations using Framer Motion. Just remember, don’t go overboard with it! 😉

Why do we need Framer Motion for this?

While CSS boasts an array of impressive features, handling staggered animations isn’t one of its strong suits. If you’re looking to animate individual letters, you’d typically need to assign custom delays using selectors like nth-child(). However, this can be quite a hassle when you’re unsure about the text’s exact letter count.

And if you want to replicate a typing effect with animation repetition, CSS can present an even greater challenge. This is because it doesn’t allow you to set a specific delay between the end of one animation and the start of the next – you can only set a delay before the animation begins.

Ultimate goal

So, before we dive into the details, what’s the big picture here? I’ve got a vision for creating an AnimatedText component with the following features:

  • The ability to handle any text input
  • Compatibility with multi-line text
  • Staggered letter animations for added flair
  • User-friendly animation controls
  • Optional animation repetition for that realistic typing effect
  • Scroll-triggered animations
  • Using the component should be as simple as this:
1
<AnimatedText
2
text={[
3
"This is written on",
4
"a typing machine. Tick tick",
5
"tick tack tack...",
6
]}
7
className="text-4xl"
8
repeatDelay={10000}
9
animation={{
10
hidden: { opacity: 0, y: 20 },
11
visible: { opacity: 1, y: 0, transition: { staggerChildren: 0.1 } }
12
}}
13
/>

Start building the AnimatedText component

Now, let’s roll up our sleeves and begin constructing the AnimatedText component, one step at a time. Our initial move is to receive a word and break it down into individual letters. We’ll achieve this by employing the split("") method on the string, which neatly furnishes us with an array of letters.

1
type AnimatedTextProps = {
2
text: string;
3
el?: keyof JSX.IntrinsicElements;
4
className?: string;
5
};
6
7
export const AnimatedText = ({
8
text,
9
el: Wrapper = "p",
10
className,
11
}: AnimatedTextProps) => {
12
return (
13
<Wrapper className={className}>
14
<span className="sr-only">{text}</span>
15
16
<span aria-hidden>
17
{text.split("").map((char, charIndex) => (
18
<motion.span
19
key={`${char}-${charIndex}`}
20
className="inline-block"
21
>
22
{char}
23
</motion.span>
24
))}
25
</span>
26
27
</Wrapper>
28
);
29
};

Here, we kick off with a few crucial initial steps.

Take a look at line 14, where we render the text within a sr-only span. This means that the content within this span remains hidden from view but is exclusively accessible to screen readers.

Now, at line 16, you’ll notice the opposite approach – content hidden from screen readers but visible to users. This is the text that gets split into individual letters.

Rendering the text twice serves a specific purpose. Occasionally, when letters are split into separate spans, screen readers may interpret them as an abbreviation rather than a complete word. To address this, I’ve opted to render the full word within an sr-only span. This ensures that what’s read aloud to the user is the complete word or sentence.

Let’s add some animations

Now that we have this foundation, it’s time to add some life to it. Our first step will be to add a subtle fade-in animation to each letter.

1
export const AnimatedText = ({
2
text,
3
el: Wrapper = "p",
4
className,
5
}: AnimatedTextProps) => {
6
return (
7
<Wrapper className={className}>
8
<span className="sr-only">{text}</span>
9
10
<span aria-hidden>
11
{word.split("").map((char, charIndex) => (
12
<motion.span
13
initial={{ opacity: 0 }}
14
animate={{ opacity: 1 }}
15
key={`${char}-${charIndex}`}
16
className="inline-block"
17
>
18
{char}
19
</motion.span>
20
))}
21
</span>
22
23
</Wrapper>
24
);
25
};

That results in the following fade in animation:

Fading..

Boooooring

Well, I’m sure you didn’t start reading this article for a basic fade-in animation – that’s something you can easily achieve with CSS. Let’s take it up a notch and make it stagger!

To accomplish this, we’ll employ animation variants. This approach involves using distinct names for animation states and toggling between them, rather than directly applying styles to initial and animate. Our code should be modified to resemble the following:

1
const defaultAnimation = {
2
hidden: {
3
opacity: 0,
4
},
5
visible: {
6
opacity: 1,
7
},
8
};
9
10
export const AnimatedText = ({
11
text,
12
el: Wrapper = "p",
13
className,
14
}: AnimatedTextProps) => {
15
return (
16
<Wrapper className={className}>
17
<span className="sr-only">{text}</span>
18
19
<motion.span aria-hidden>
20
{word.split("").map((char, charIndex) => (
21
<motion.span
22
variants={defaultAnimation}
23
key={`${char}-${charIndex}`}
24
className="inline-block"
25
>
26
{char}
27
</motion.span>
28
))}
29
</motion.span>
30
31
</Wrapper>
32
);
33
};

Note the new defaultAnimation object. Within this object, we’ve defined various animation states. The property names are entirely customizable, and within each state, we specify the styles we want to apply.

Next, we changed our parent element to a motion.span instead of a standard span. This change is essential because we intend to apply the animation to the parent container rather than to individual letters. We then remove the initial and animate props, and use the variants prop instead..

At this stage, it won’t be any different to what we had before. However, the magic happens when we add a transition prop to the parent’s variants, instructing it to staggerChildren with a delay of 0.1. It looks something like this:

1
const defaultAnimation = {
2
hidden: {
3
opacity: 0,
4
},
5
visible: {
6
opacity: 1,
7
},
8
};
9
10
export const AnimatedText = ({
11
text,
12
el: Wrapper = "p",
13
className,
14
}: AnimatedTextProps) => {
15
return (
16
<Wrapper className={className}>
17
<span className="sr-only">{text}</span>
18
19
<motion.span
20
variants={{
21
visible: { transition: {staggerChildren: 0.1 }},
22
hidden: {}
23
}}
24
initial="hidden"
25
animate="visible"
26
aria-hidden>
27
{word.split("").map((char, charIndex) => (
28
<motion.span
29
variants={defaultAnimation}
30
key={`${char}-${charIndex}`}
31
className="inline-block"
32
>
33
{char}
34
</motion.span>
35
))}
36
</motion.span>
37
38
</Wrapper>
39
);
40
};

What’s happening here? First and foremost, we’re introducing the variants to the parent element, making it aware of their existence. However, we don’t define any styles for the parent because its styles won’t change based on the variants. What we do set, though, is the transition prop within the visible state. We tell it to stagger it’s children when applying the variants.

Next, we reintroduce the initial and animate props for the parent. Instead of specifying styles, we now assign the variant names as the values.

Thanks to the way Framer Motion operates, the variants we’ve established for the parent will automatically cascade down to its children if they don’t have their own variant definitions. As a result, we no longer need to explicitly pass the variants to the children, and just like that, our letters begin animating one by one!

Fading..

Cool. But if this happens below the fold, you’d never see it..

Now, let’s move on to the next step: ensuring that this animation only kicks in when the text becomes visible within the user’s view. To achieve this, we’ll harness the power of the useInView hook provided by Framer Motion.

1
const defaultAnimation = { }; // see previous snippet
2
3
export const AnimatedText = ({
4
text,
5
el: Wrapper = "p",
6
className,
7
}: AnimatedTextProps) => {
8
const ref = useRef(null);
9
const isInView = useInView(ref, { amount: 0.5, once: true });
10
11
return (
12
<Wrapper className={className}>
13
<span className="sr-only">{text}</span>
14
15
<motion.span
16
ref={ref}
17
variants={{
18
visible: { transition: {staggerChildren: 0.1 }},
19
hidden: {}
20
}}
21
initial="hidden"
22
animate={isInView ? "visible" : "hidden"}
23
aria-hidden>
24
{word.split("").map((char, charIndex) => (
25
<motion.span
26
variants={defaultAnimation}
27
key={`${char}-${charIndex}`}
28
className="inline-block"
29
>
30
{char}
31
</motion.span>
32
))}
33
</motion.span>
34
35
</Wrapper>
36
);
37
};

We add a ref to the parent element and pass it to the useInView hook. This hook returns a boolean value, indicating whether the text is currently within the user’s view or not. We leverage this boolean to determine whether the text should undergo animation or not, achieved by setting the appropriate variant in the animate prop. Pretty neat, isn’t it?

Animation Triggering, Just Once

By default, I’ve configured the once boolean in the hook to be true. However, you can set it to false if you’d like the animation to activate every time the text enters the user’s view.

Enabling Animation Looping

As a final touch in the video, we add the looping functionality. To fully understand how this works, I recommend watching the full video. In brief, it entails converting our animation into a controlled animation. This way, we can trigger the animation repeatedly within a specified timeout. The final version of the code would then resemble this:

The final result,even working on multiple lines..

1
import { motion, useInView, useAnimation, Variant } from "framer-motion";
2
import { useEffect, useRef } from "react";
3
4
type AnimatedTextProps = {
5
text: string | string[];
6
el?: keyof JSX.IntrinsicElements;
7
className?: string;
8
once?: boolean;
9
repeatDelay?: number;
10
animation?: {
11
hidden: Variant;
12
visible: Variant;
13
};
14
};
15
16
const defaultAnimations = {
17
hidden: {
18
opacity: 0,
19
y: 20,
20
},
21
visible: {
22
opacity: 1,
23
y: 0,
24
transition: {
25
duration: 0.1,
26
},
27
},
28
};
29
30
export const AnimatedText = ({
31
text,
32
el: Wrapper = "p",
33
className,
34
once,
35
repeatDelay,
36
animation = defaultAnimations,
37
}: AnimatedTextProps) => {
38
const controls = useAnimation();
39
const textArray = Array.isArray(text) ? text : [text];
40
const ref = useRef(null);
41
const isInView = useInView(ref, { amount: 0.5, once });
42
43
useEffect(() => {
44
let timeout: NodeJS.Timeout;
45
const show = () => {
46
controls.start("visible");
47
if (repeatDelay) {
48
timeout = setTimeout(async () => {
49
await controls.start("hidden");
50
controls.start("visible");
51
}, repeatDelay);
52
}
53
};
54
55
if (isInView) {
56
show();
57
} else {
58
controls.start("hidden");
59
}
60
61
return () => clearTimeout(timeout);
62
}, [isInView]);
63
64
return (
65
<Wrapper className={className}>
66
<span className="sr-only">{text}</span>
67
<motion.span
68
ref={ref}
69
initial="hidden"
70
animate={controls}
71
variants={{
72
visible: { transition: { staggerChildren: 0.1 } },
73
hidden: {},
74
}}
75
aria-hidden
76
>
77
{textArray.map((line, lineIndex) => (
78
<span className="block" key={`${line}-${lineIndex}`}>
79
{line.split(" ").map((word, wordIndex) => (
80
<span className="inline-block" key={`${word}-${wordIndex}`}>
81
{word.split("").map((char, charIndex) => (
82
<motion.span
83
key={`${char}-${charIndex}`}
84
className="inline-block"
85
variants={animation}
86
>
87
{char}
88
</motion.span>
89
))}
90
<span className="inline-block">&nbsp;</span>
91
</span>
92
))}
93
</span>
94
))}
95
</motion.span>
96
</Wrapper>
97
);
98
};

Make sure to watch the full video

That sums up the creation of this animation. Make sure to watch the full video at the top of this page, to get an even better idea of how we construct this animation. We go way more in depth there.