In this article I’ll go through how I achieved the animation effect below, which I use for headings throughout my portfolio. Take a look:
Prerequisites
Off the bat, Framer Motion is an animation library for React, so you’ll need to be working on a React project to follow along – I’m using NextJS. Framer Motion can be installed with:
npm install framer-motion
The Intersection Observer Web API is used to detect when an element moves into the viewport. The easiest way to use this with React is to install the react-intersection-observer
package, which will give us some nice hooks to work with:
npm install react-intersection-observer
I’ll also be using styled-components for CSS, although any styling methods will work.
Making it happen
To start, let’s create a new component for our animated text, such as AnimatedTitle.js
– this way we’ll be able to re-use the component with whatever text we’d like throughout our site.
Breaking words into characters
In order to animate words character by character, we’re going to need to separate each character out into its own <span>
element. This is fairly simple to do using a .map()
function:
1//AnimatedTitle.js
2
3import { useEffect } from "react";
4import styled from "styled-components";
5import { useAnimation, motion } from "framer-motion";
6import { useInView } from "react-intersection-observer";
7
8const Title = styled.h2`
9 font-size: 3rem;
10 font-weight: 600;
11`;
12
13const Character = styled(motion.span)`
14 display: inline-block;
15 margin-right: -0.05em;
16`;
17
18export default function AnimatedTitle() {
19 const text = 'Animated Text'; // This would normally be passed into this component as a prop!
20
21 const ctrls = useAnimation();
22
23 const { ref, inView } = useInView({
24 threshold: 0.5,
25 triggerOnce: true,
26 });
27
28 useEffect(() => {
29 if (inView) {
30 ctrls.start("visible");
31 }
32 if (!inView) {
33 ctrls.start("hidden");
34 }
35 }, [ctrls, inView]);
36
37 const characterAnimation = {
38 hidden: {
39 opacity: 0,
40 y: `0.25em`,
41 },
42 visible: {
43 opacity: 1,
44 y: `0em`,
45 transition: {
46 duration: 1,
47 ease: [0.2, 0.65, 0.3, 0.9],
48 },
49 },
50 };
51
52 return (
53 <Title aria-label={text} role="heading">
54 {text.split("").map((character, index) => (
55 <Character
56 ref={ref}
57 aria-hidden="true"
58 key={index}
59 initial="hidden"
60 animate={ctrls}
61 variants={characterAnimation}
62 >
63 {character}
64 </Character>
65 );
66 }
67 </Title>
68 );
69}
Let's break down some of this code. I’ve defined some styled-components for the overall title, and each character – you’ll notice that it’s the Character
component which uses the motion.span
tag which’ll let us animate it!
1const ctrls = useAnimation();
The useAnimation
hook allows us to define animations and then apply them to our components, such as Character
.
1const { ref, inView } = useInView({
2 threshold: 0.5,
3 triggerOnce: true,
4});
5
6useEffect(() => {
7 if (inView) {
8 ctrls.start("visible");
9 }
10 if (!inView) {
11 ctrls.start("hidden");
12 }
13}, [ctrls, inView]);
The useInView
hook will trigger the animation only once the text is in view – providing those nice reveal animations we’re after as a user scrolls the page.
1const characterAnimation = {
2 hidden: {
3 opacity: 0,
4 y: `0.25em`,
5 },
6 visible: {
7 opacity: 1,
8 y: `0em`,
9 transition: {
10 duration: 1,
11 ease: [0.2, 0.65, 0.3, 0.9],
12 },
13 },
14};
Here’s where we’ve defined our character animation – each character will move 1rem
upwards and become visible (opacity from 0 to 1) over 1 second, with a cubic-bezier ease-out animation making it appear nice and smooth.
1<Title aria-label={text} role="heading">
2 {text.split("").map((character, index) => (
3 <Character
4 ref={ref}
5 aria-hidden="true"
6 key={index}
7 initial="hidden"
8 animate={ctrls}
9 variants={characterAnimation}
10 >
11 {character}
12 </Character>
13 );
14 }
15</Title>
Within the Title
component, we split the text every character, and map each of these characters to our Character
component, each of which will animate one by one.
Accessibility
Once we split the text into character spans
, the browser will lose track of the fact that each character actually makes up a word. We want the copy to be computer-readable and accessible as well, so we’ll set the aria-label
on the Title
component with the role "heading", and then we’ll hide each individual Character
from the browser & screen readers using aria-hidden="true"
.
So, this is what we've got at this stage:
As you can see, we’re getting a bit of the effect we’re after, but without two key things:
- Our text is no longer written in words – all the characters have bunched together.
- There’s no staggering of our animation – this is a crucial part in helping the animation not feel so flat!
Words and animation staggering
The solution here is fairly simple – we'll add an additional Word
component to our code.
1// AnimatedTitle.js
2
3import { useEffect } from "react";
4import styled from "styled-components";
5import { useAnimation, motion } from "framer-motion";
6import { useInView } from "react-intersection-observer";
7
8const Title = styled.h2`
9 font-size: 3rem;
10 font-weight: 600;
11`;
12
13const Word = styled(motion.span)`
14 display: inline-block;
15 margin-right: 0.25em;
16 white-space: nowrap;
17`;
18
19const Character = styled(motion.span)`
20 display: inline-block;
21 margin-right: -0.05em;
22`;
23
24export default function AnimatedTitle() {
25 const text = 'Animated Text' // This would normally be passed into this component as a prop!
26
27 const ctrls = useAnimation();
28
29 const { ref, inView } = useInView({
30 threshold: 0.5,
31 triggerOnce: true,
32 });
33
34 useEffect(() => {
35 if (inView) {
36 ctrls.start("visible");
37 }
38 if (!inView) {
39 ctrls.start("hidden");
40 }
41 }, [ctrls, inView]);
42
43 const wordAnimation = {
44 hidden: {},
45 visible: {},
46 };
47
48 const characterAnimation = {
49 hidden: {
50 opacity: 0,
51 y: `0.25em`,
52 },
53 visible: {
54 opacity: 1,
55 y: `0em`,
56 transition: {
57 duration: 1,
58 ease: [0.2, 0.65, 0.3, 0.9],
59 },
60 },
61 };
62
63 return (
64 <Title aria-label={text} role="heading">
65 {text.split(" ").map((word, index) => {
66 return (
67 <Word
68 ref={ref}
69 aria-hidden="true"
70 key={index}
71 initial="hidden"
72 animate={ctrls}
73 variants={wordAnimation}
74 transition={{
75 delayChildren: index * 0.25,
76 staggerChildren: 0.05,
77 }}
78 >
79 {word.split("").map((character, index) => {
80 return (
81 <Character
82 aria-hidden="true"
83 key={index}
84 variants={characterAnimation}
85 >
86 {character}
87 </Character>
88 );
89 })}
90 </Word>
91 );
92 })}
93 </Title>
94 );
95}
Let's break down the additions to our code.
We've added a new Word
styled-component, again utilising the motion.span
element. Note the margin-right: 0.25em
property – this will simulate spaces between the words (usually space characters are around 0.25em in width, but depending on the font you’re using you might find a different value works best), while display: inline-block
will allow words to wrap onto new lines.
We’ve defined a new wordAnimation
– which is actually empty. This is because we don’t want the word itself to animate (we’ll animate each character), but we need to define an animation in order to trigger the child character animations when the word comes into the viewport.
Finally, we’ve added an additional .map()
function for each word, with the character map having been updated to run within each word, instead of within the overall text component.
The important code here is the transition
prop on our Word
component – this is where we can define the staggering of the character animation.
staggerChildren
: 0.05 here tells each letter to delay it’s animation start by 0.05 seconds, providing a nice reveal animation on each word.delayChildren
: This property is how we tell each word to start slightly after the previous one. Usingindex * 0.25
the first word will animate immediately, and each subsequent word will start after an additional 0.25 seconds. I’ve found this works reasonably well – there is probably a slightly better solution which takes into account the previous word’s length but my solution here works fine for titles where I’m using it.
And there we have it! You should have a component which looks and functions like what we saw earlier:
I hope this article was helpful! Thanks for checking it out.