Animating text with the Intersection Observer API and Framer Motion

Animation
Javascript
CSS

While building my portfolio, I wanted to add some animation to the copy, to elevate the design. Here's how I achieved the effect you can see throughout my site.

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:

  1. Our text is no longer written in words – all the characters have bunched together.
  2. 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. Using index * 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.