Highlighting Text on Scroll

Photo by Wolfgang Hasselmann on Unsplash

Highlighting Text on Scroll

In this article, we’ll be building another scroll-linked animation effect using React and Framer Motion. This time, highlighting a paragraph of text as we scroll through the containing element.

highlighting text while scrolling example

It’s a simple but visually interesting effect, and as you’ll see, building it is fairly straightforward!

The inspiration for this comes from an older version of the lucidmotors.com homepage.

lucid motors homepage inspiration

If you missed the first article in this series where we build a Scroll-Linked Content Reveal Animation, please check it out!

One thing this article does not go into detail about is when words wrap as the screen is resized. Handling that is beyond the scope of this article, but you’d likely need to implement event listeners on the page to recalculate the logic we write, or CSS breakpoints to show a different instance of the component depending on the screen size. Both of these involve more code and will vary depending on your use case.

Getting Started#

Since we’ll be building a React component to handle this functionality, the first thing to do is determine the props to pass.

Our component will take a single prop named content , which is an array of strings.

const content = [
'Lorem ipsum dolor sit amet, consectetur adipiscing elit,',
'sed do eiusmod tempor incididunt ut labore et',
'dolore magna aliqua. Ut enim ad minim veniam',
];

The content is broken up like this to give us a simple, predictable way to output the copy as individual lines. We’ll discuss why in a minute.

content prop diagram

If this approach feels rigid to you, it would be possible to write some logic to parse the content prop and split it into an array automatically. But to simplify this article, we are passing manually separated text strings.

Next, make a new file named ScrollText.js. The component name should match the file name.

export const ScrollText = ({ content }) => { ... }

Finally, make sure you have framer-motion installed and import it in the component. This article was written using version 10.9.1.

To start with, import motion and useScroll.

import { motion, useScroll } from 'framer-motion';

How it Works#

This effect works by storing a DOM reference (using a React ref) for each line of text and determining its scroll position relative to the viewport using the Framer Motion useScroll hook.

content prop diagram

We’ll use that scroll position, along with the CSS clip-path property to reveal a duplicate copy of text positioned above the bottom text.

Markup#

Now that we’ve determined how the component will work, let’s build it!

First, the component should output two divs: an outer that can contain a color or background image, and an inner to keep the text contained to a specific width.

export const ScrollText = ({ content }) => {
return (
<div className="outer">
<div className="inner">
...
</div>
</div>
);
}

In the inner div, we map over each sentence passed in via the content prop.

<div className="inner">
<p>
{content.map((item) => (
<ContentLine
key={item}
content={item}
/>
))}
</p>
</div>

You could substitute using a unique ID or some other value as the key. We’ll use the text itself for this example, but that may not work in all scenarios.

At this point we will create a second component named ContentLine. For the purposes of this article, you can add it to the same file along with ScrollText.

The ContentLine component helps compartmentalize the functionality of each line of text, since they all track their own scroll progress.

ContentLine renders the content passed to it in three span elements. The outermost text-container span will help with positioning and overflow.

We’ll need to reference this element, so use the useRef hook.

import { useRef } from 'react';
const ContentLine = ({ content }) => {
const contentRef = useRef();
return (
<span className="text-container" ref={contentRef}>
...
</span>
);
}

Within text-container, we have the highlighted-text span, which is the text that’s highlighted when scrolling.

We’ll pass a data-text attribute here containing the content. In the next section, this attribute is referenced to output a “cloned” copy of this text.

return (
<span className="text-container" ref={contentRef}>
<motion.span
className="highlighted-text"
data-text={content}
/>
</span>
);

You may notice this is the first time we’re using the motion component. We need this because we will be passing the element an animation value derived by Framer Motion and motion needs to be used in order for it to be animated properly.

You can learn more about the motion component here, or refer to the previous article in this series for a more in depth explanation.

Last, we have the static-text that appears underneath the highlighted text.

return (
<span className="text-container" ref={contentRef}>
<motion.span
className="highlighted-text"
data-text={content}
/>
<span className="static-text">
{content}
</span>
</span>
);

CSS#

With the markup in place, let’s add some CSS.

Some of this will vary depending on your use case, so we’ll only focus on the important styles. Please reference the demo for the full CSS.

First, we have the outer div. This can be styled however you want, but in the demo we’re using a background image with a CSS grayscale filter.

.outer {
background: url('./main.jpg');
background-size: cover;
background-position: center;
filter: grayscale(50%);
}

Then some basic styles for the inner paragraph text and the text-container.

.inner p {
display: inline;
line-height: 1.3;
}
.text-container {
position: relative;
overflow: hidden;
display: block;
}

Hello! If you find this content helpful, please consider supporting this project. 🙂

Let's discuss how to duplicate the text.

In the previous section, we used a data-text attribute on the static-text span. This gives us access via the CSS content property to output each line of text as a before pseudo element.

.highlighted-text:before {
content: attr(data-text);
display: inline-block;
opacity: 0.8;
}

This is a good use case for a psuedo element because it prevents us from having duplicate content on the page for the sole purpose of the highlight effect.

The last bit of CSS is for the highlighted-text.

.highlighted-text {
color: #111;
width: 100%;
height: 100%;
position: absolute;
left: 0;
z-index: 0;
transition: clip-path 0.1s ease;
text-shadow: 0 0 0 #111;
}

This positions the highlighted-text directly over the top of the static-text. It also sets a transition on the clip-path property (the property we’ll be animating) to give it a very slight visual delay. Adjust this value yourself to see what works for you.

Add the position: absolute; property last to see the text move into place.

content prop diagram

Framer Motion Hooks#

With the markup and styles done, let’s work with Framer Motion!

As mentioned above, the component will use three React hooks that Framer Motion provides: useScroll, useTransform and useMotionTemplate.

First, to get the scroll progress of each line of text inside the viewport, we add useScroll.

The target param should be a reference to text-container and the offset describes where the element and the viewport meet.

const ContentLine = ({ content }) => {
const contentRef = useRef();
const { scrollYProgress } = useScroll({
target: contentRef,
offset: ["end center", "start start"]
});
...
}

Offsets are a bit tricky to explain. Please refer to the Framer Motion docs for more detail, but it’s usually best to just experiment with values and see what works for you.

The above gives us the scroll progress as scrollYProgress, but it’s not quite what we need. Since we want to set the clip-path of the text from 100% to 0%, we need to transform this value.

Framer Motion includes a hook named useTransform to transform one set of values into another.

It’ll take in the scrollYProgress value, and two arrays.

The first array you can think of as sort of a threshold. It controls the time from when one row of text finishes highlighting until the next line starts. After some experimentation, [0, 0.15] seemed to work relatively well, but this number is somewhat arbitrary and requires a bit of trial and error.

The second array is straightforward and describes the start and end percentages: starting at 100% and ending at 0%.

const ContentLine = ({ content }) => {
const contentRef = useRef(null);
const { scrollYProgress } = useScroll({
target: contentRef,
offset: ["end center", "start start"]
});
const scrollValue = useTransform(scrollYProgress, [0, 0.15], ['100%', '0%']);
...
}

We’re almost there! All we need now is to take the scrollValue and apply it to the highlighted-text span.

Framer Motion has yet another hook that can help us out here: useMotionTemplate.

const clipPathVal = useMotionTemplate`inset(0% ${scrollValue} 0% 0%)`;

If we pass it the scrollValue calculated above, it’ll output a motion value that contains both dynamic and static (unchanging) values. For example, inset(0% 100% 0% 0%), inset(0% 20% 0% 0%), etc.

Finally, once we have the clipPathVal created, we pass it as a style property to the highlighted-text span.

const ContentLine = ({ content }) => {
...
const clipPathVal = useMotionTemplate`inset(0% ${scrollValue} 0% 0%)`;
return (
<span className="text-container" ref={contentRef}>
<motion.span
style={{ clipPath: clipPathVal }}
className="highlighted-text"
data-text={content}
/>
<span className="static-text">
{content}
</span>
</span>
);
}

Why do we need to use a hook instead of a template string for the style object? Why can't we just do something like this?

style={{ clipPath: `inset(0% ${scrollValue} 0% 0%)` }}

motion components need to be passed a special type of “motion value” for animations to work, and using a template string for the style property directly won’t work. It needs to be run through something that returns a motion value and useMotionTemplate does that.

You can read more about motion values in the Framer Motion docs.

Conclusion#

This article provided a step-by-step guide to building a scroll-linked animation in React using Framer Motion and explains how to use the useScroll, useTransform, and useMotionTemplate hooks.

Framer Motion is the tool of choice in this series, though the same effect could have been achieved in many other ways.