Scroll-driven animations are great! They’re a powerful tool that lets developers tie the movement and transformation of elements directly to the user’s scroll position. This technique opens up new ways to create interactive experiences, cuing images to appear, text to glide across the stage, and backgrounds to subtly shift. Used thoughtfully, scroll-driven animations (SDA) can make your website feel more dynamic, engaging, and responsive.
A few weeks back, I was playing around with scroll-driven animations, just searching for all sorts of random things you could do with it. That’s when I came up with the idea to animate the text of the main heading (h1
) and, using SDA, change the heading itself based on the user’s scroll position on the page. In this article, we’re going to break down that idea and rebuild it step by step. This is the general direction we’ll be heading in, which looks better in full screen and viewed in a Chromium browser:
It’s important to note that the effect in this example only works in browsers that support scroll-driven animations. Where SDA isn’t supported, there’s a proper fallback to static headings. From an accessibility perspective, if the browser has reduced motion enabled or if the page is being accessed with assistive technology, the effect is disabled and the user gets all the content in a fully semantic and accessible way.
Just a quick note: this approach does rely on a few “magic numbers” for the keyframes, which we’ll talk about later on. While they’re surprisingly responsive, this method is really best suited for static content, and it’s not ideal for highly dynamic websites.
Closer Look at the Animation
Before we dive into scroll-driven animations, let’s take a minute to look at the text animation itself, and how it actually works. This is based on an idea I had a few years back when I wanted to create a typewriter effect. At the time, most of the methods I found involved animating the element’s width, required using a monospace font, or a solid color background. None of which really worked for me. So I looked for a way to animate the content itself, and the solution was, as it often is, in pseudo-elements.
Pseudo-elements have a content
property, and you can (kind of) animate that text. It’s not exactly animation, but you can change the content dynamically. The cool part is that the only thing that changes is the text itself, no other tricks required.
Start With a Solid Foundation
Now that you know the trick behind the text animation, let’s see how to combine it with a scroll-driven animation, and make sure we have a solid, accessible fallback as well.
We’ll start with some basic semantic markup. I’ll wrap everything in a main
element, with individual sections inside. Each section
gets its own heading and content, like text and images. For this example, I’ve set up four sections, each with a bit of text and some images, all about Primary Colors.
<main>
<section>
<h1>Primary Colors</h1>
<p>The three primary colors (red, blue, and yellow) form the basis of all other colors on the color wheel. Mixing them in different combinations produces a wide array of hues.</p>
<img src="./colors.jpg" alt="...image description">
</section>
<section>
<h2>Red Power</h2>
<p>Red is a bold and vibrant color, symbolizing energy, passion, and warmth. It easily attracts attention and is often linked with strong emotions.</p>
<img src="./red.jpg" alt="...image description">
</section>
<section>
<h2>Blue Calm</h2>
<p>Blue is a calm and cool color, representing tranquility, stability, and trust. It evokes images of the sky and sea, creating a peaceful mood.</p>
<img src="./blue.jpg" alt="...image description">
</section>
<section>
<h2>Yellow Joy</h2>
<p>Yellow is a bright and cheerful color, standing for light, optimism, and creativity. It is highly visible and brings a sense of happiness and hope.</p>
<img src="./yellow.jpg" alt="...image description">
</section>
</main>
As for the styling, I’m not doing anything special at this stage, just the basics. I changed the font and adjusted the text and heading sizes, set up the display
for the main
and the section
s, and fixed the image sizes with object-fit
.
So, at this point, we have a simple site with static, semantic, and accessible content, which is great. Now the goal is to make sure it stays that way as we start adding our effect.
The Second First Heading
We’ll start by adding another h1
element at the top of the main
. This new element will serve as the placeholder for our animated text, updating according to the user’s scroll position. And yes, I know there’s already an h1
in the first section
; that’s fine and we’ll address it in a moment so that only one is accessible at a time.
<h1 class="scrollDrivenHeading" aria-hidden="true">Primary Colors</h1>
Notice that I’ve added aria-hidden="true"
to this heading, so it won’t be picked up by screen readers. Now I can add a class specifically for screen readers, .srOnly
, to all the other headings. This way, anyone viewing the content “normally” will see only the animated heading, while assistive technology users will get the regular, static semantic headings.
Note: The style for the .srOnly
class is based on “Inclusively Hidden” by Scott O’Hara.
Handling Support
As much as accessibility matters, there’s another concern we need to keep in mind: support. CSS Scroll-Driven Animations are fantastic, but they’re still not fully supported everywhere. That’s why it’s important to provide the static version for browsers that don’t support SDA.
The first step is to hide the animated heading we just added using display: none
. Then, we’ll add a new @supports
block to check for SDA support. Inside that block, where SDA is supported, we can change back the display for the heading.
The .srOnly
class should also move into the @supports
block, since we only want it to apply when the effect is active, not when it’s not supported. This way, just like with assistive technology, anyone visiting the page in a browser without SDA support will still get the static content.
.scrollDrivenHeading {
display: none;
}
@supports (animation-timeline: scroll()) {
.scrollDrivenHeading {
display: block;
}
/* Screen Readers Only */
.srOnly {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
}
Get Sticky
The next thing we need to do is handle the stickiness of the heading. To make sure the heading always stays on screen, we’ll set its position
to sticky
with top: 0
so it sticks to the top of the viewport.
While we’re at it, let’s add some basic styling, including a background so the text doesn’t blend with whatever’s behind the heading, a bit of padding
for spacing, and white-space: nowrap
to keep the heading on a single line.
/* inside the @supports block */
.scrollDrivenHeading {
display: block;
position: sticky;
top: 0;
background-image: linear-gradient(0deg, transparent, black 1em);
padding: 0.5em 0.25em;
white-space: nowrap;
}
Now everything’s set up: in normal conditions, we’ll see a single sticky heading at the top of the page. And if someone uses assistive technology or a browser that doesn’t support SDA, they’ll still get the regular static content.
Now we’re ready to start animating the text. Almost…
The Magic Numbers
To build the text animation, we need to know exactly where the text should change. With SDA, scrolling basically becomes our timeline, and we have to determine the exact points on that timeline to trigger the animation.
To make this easier, and to help you pinpoint those positions, I’ve prepared the following script:
@property --scroll-position {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
body::after {
counter-reset: sp var(--scroll-position);
content: counter(sp) "%";
position: fixed;
top: 0;
left: 0;
padding: 1em;
background-color: maroon;
animation: scrollPosition steps(100);
animation-timeline: scroll();
}
@keyframes scrollPosition {
0% { --scroll-position: 0; }
100% { --scroll-position: 100; }
}
I don’t want to get too deep into this code, but the idea is to take the same scroll timeline we’ll use next to animate the text, and use it to animate a custom property (--scroll-position
) from 0
to 100
based on the scroll progress, and display that value in the content.
If we’ll add this at the start of our code, we’ll see a small red square in the top-left corner of the screen, showing the current scroll position as a percentage (to match the keyframes). This way, you can scroll to any section you want and easily mark the percentage where each heading should begin.
With this method and a bit of trial and error, I found that I want the headings to change at 30%, 60%, and 90%. So, how do we actually do it? Let’s start animating.
Animating Text
First, we’ll clear out the content inside the .scrollDrivenHeading
element so it’s empty and ready for dynamic content. In the CSS, I’ll add a pseudo-element to the heading, which we’ll use to animate the text. We’ll give it empty content
, set up the animation-name
, and of course, assign the animation-timeline
to scroll()
.
And since I’m animating the content
property, which is a discrete type, it doesn’t transition smoothly between values. It just jumps from one to the next. By setting the animation-timing-function
property to step-end
, I make sure each change happens exactly at the keyframe I define, so the text switches precisely where I want it to, instead of somewhere in between.
.scrollDrivenHeading {
/* style */
&::after {
content: '';
animation-name: headingContent;
animation-timing-function: step-end;
animation-timeline: scroll();
}
}
As for the keyframes, this part is pretty straightforward (for now). We’ll set the first frame (0%
) to the first heading, and assign the other headings to the percentages we found earlier.
@keyframes headingContent {
0% { content: 'Primary Colors'}
30% { content: 'Red Power'}
60% { content: 'Blue Calm'}
90%, 100% { content: 'Yellow Joy'}
}
So, now we’ve got a site with a sticky heading that updates as you scroll.
But wait, right now it just switches instantly. Where’s the animation?! Here’s where it gets interesting. Since we’re not using JavaScript or any string manipulation, we have to write the keyframes ourselves. The best approach is to start from the target heading you want to reach, and build backwards. So, if you want to animate between the first and second heading, it would look like this:
@keyframes headingContent {
0% { content: 'Primary Colors'}
9% { content: 'Primary Color'}
10% { content: 'Primary Colo'}
11% { content: 'Primary Col'}
12% { content: 'Primary Co'}
13% { content: 'Primary C'}
14% { content: 'Primary '}
15% { content: 'Primary'}
16% { content: 'Primar'}
17% { content: 'Prima'}
18% { content: 'Prim'}
19% { content: 'Pri'}
20% { content: 'Pr'}
21% { content: 'P'}
22% { content: 'R'}
23% { content: 'Re'}
24% { content: 'Red'}
25% { content: 'Red '}
26% { content: 'Red P'}
27% { content: 'Red Po'}
28%{ content: 'Red Pow'}
29% { content: 'Red Powe'}
30% { content: 'Red Power'}
60% { content: 'Blue Calm'}
90%, 100% { content: 'Yellow Joy'}
}
I simply went back by 1% each time, removing or adding a letter as needed. Note that in other cases, you might want to use a different step size, and not always 1%. For example, on longer headings with more words, you’ll probably want smaller steps.
If we repeat this process for all the other headings, we’ll end up with a fully animated heading.
User Preferences
We talked before about accessibility and making sure the content works well with assistive technology, but there’s one more thing you should keep in mind: prefers-reduced-motion
. Even though this isn’t a strict WCAG requirement for this kind of animation, it can make a big difference for people with vestibular sensitivities, so it’s a good idea to offer a way to show the content without animations.
If you want to provide a non-animated alternative, all you need to do is wrap your @supports
block with a prefers-reduced-motion
query:
@media screen and (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: scroll()) {
/* style */
}
}
Leveling Up
Let’s talk about variations. In the previous example, we animated the entire heading text, but we don’t have to do that. You can animate just the part you want, and use additional animations to enhance the effect and make things more interesting. For example, here I kept the text “Primary Color” fixed, and added a span
after it that handles the animated text.
<h1 class="scrollDrivenHeading" aria-hidden="true">
Primary Color<span></span>
</h1>
And since I now have a separate span
, I can also animate its color to match each value.
In the next example, I kept the text animation on the span
, but instead of changing the text color, I added another scroll-driven animation on the heading itself to change its background color. This way, you can add as many animations as you want and change whatever you like.
Your Turn!
CSS Scroll-Driven Animations are more than just a cool trick; they’re a game-changer that opens the door to a whole new world of web design. With just a bit of creativity, you can turn even the most ordinary pages into something interactive, memorable, and truly engaging. The possibilities really are endless, from subtle effects that enhance the user experience, to wild, animated transitions that make your site stand out.
So, what would you build with scroll-driven animations? What would you create with this new superpower? Try it out, experiment, and if you come up with something cool, have some ideas, wild experiments, or even weird failures, I’d love to hear about them. I’m always excited to see what others come up with, so feel free to share your work, questions, or feedback below.
Special thanks to Cristian Díaz for reviewing the examples, making sure everything is accessible, and contributing valuable advice and improvements.
Scroll-Driven Sticky Heading originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Source: Read MoreÂ