Scroll-driven Animations Using CSS
Earlier this year, I kept seeing posts on my Twitter timeline about scroll-driven animations in CSS. For some reason I didn’t think much about them. But recently, I thought… maybe I should actually see what they’re about.
Intro
I’m not just going to drop a bunch of notes here. Instead, I’ll try to recreate three effects and explain the logic and ideas as I go. Feels like the easiest way for me to write this.
These effects will cover a good chunk of what it’s like working with CSS scroll-driven animations. They include things like:
- working with anonymous scroll timelines
- working with named scroll timelines
- animation ranges
- timeline scopes
- view scroll timelines
1. Scroll progress bar
I’ll start with a simple example to introduce the basics. The scroll progress bar at the top is scroll-driven.
By default, CSS animations run on the document timeline. That means the animation progresses with time from the moment the document loads in the browser. You can opt into this by setting animation-timeline: auto, though this isn’t necessary since it’s the default value.
However, we want to tie our animation to the user’s scroll. We can do that by linking the animation to the scroll progress timeline. This timeline is provided by a scrollable element (a scroller). In our case, the scroller is the root document.
Let’s start by setting up our component. A simple div will do:
<div id="scroll-progress-bar"></div>
Now for some basic styling:
#scroll-progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #f0851a;
}
We’re giving it a fixed position with top: 0 so it always stays at the top of the viewport.
Next, we’ll make the scroll animation. The idea is to scale the bar along the X-axis as the user scrolls.
Here’s the code:
#scroll-progress-bar {
transform-origin: left center;
animation: expand-progress linear forwards;
animation-timeline: scroll();
}
@keyframes expand-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
Let’s break this down, we first define an animation expand-progress with keyframes that scales the element from 0 to 1 on the X-axis. Then we apply that animation to the element with a linear easing so it moves at a constant speed, and a forwards fill so it stays in its final state when it’s done. Finally, we link it to the scroll using animation-timeline: scroll(). And just like that, the animation works.
What we have here is called an anonymous scroll timeline, a timeline without a name.
The scroll() function we used can take two parameters:
scroll( <scroller> <axis> )
scroller options:
nearest- the closest ancestor with a scrollbar (default)root- the root element of the documentself- the element itself
axis options:
block- Block axis, axis in the direction perpendicular to the flow of text within a line, of the scroller. default valueinline- Inline axis, axis in the direction parallel to the flow of text within a line, of the scroller.y- vertical axis of scrollerx- horizontal axis of scroller
Since our div is fixed, it’s positioned relative to the viewport (root scrollport), so the nearest scroller is the root. We are also scrolling on the block axis, so we don’t need to pass any parameters to scroll() since the defaults are what we need.
Changing the position to sticky may break the effect depending on
where you place your progress bar in your HTML.
2. Carousel Scroll Progress
Next up is a carousel. This effect can be used for images, comments, content blocks, whatever you like provided it’s scrollable.
Below is the component
Let’s start by setting up our component;
<div id="carousel-wrapper">
<div id="carousel-progress-bar"></div>
<div id="carousel-container">
<div class="carousel-item">1</div>
<div class="carousel-item">2</div>
<div class="carousel-item">3</div>
<div class="carousel-item">4</div>
<div class="carousel-item">5</div>
</div>
</div>
Now lets add some basic styling along with the animation logic;
#carousel-wrapper {
width: 100%;
border: 1px solid var(--border-color);
padding: 1em;
timeline-scope: --carousel-progress;
}
@keyframes expand-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
#carousel-progress-bar {
width: 100%;
height: 6px;
border-radius: 4px;
background-color: #99ffe4;
transform-origin: left center;
animation: expand-progress linear forwards;
animation-timeline: --carousel-progress;
}
#carousel-container {
margin-top: 1em;
display: flex;
overflow-x: scroll;
padding-bottom: 1em;
gap: 1em;
scroll-snap-type: x mandatory;
scroll-timeline: --carousel-progress inline;
}
.carousel-item {
width: 300px;
height: 300px;
min-width: 300px;
border-radius: 4px;
background-color: var(--border-color);
scroll-snap-align: center;
}
I have highlighted the most important parts.This effect is different from the first one in three main ways. First, we’re no longer using the root document as the scroller. Second, we’re using a named scroll timeline, giving it a name --carousel-progress so we can reference it elsewhere. And third, probably the most important, our #carousel-progress-bar isn’t a descendant of the #carousel-container.
Let`s break it down.
First, since we want the #carousel-container to act as the scroller, we give it a named scroll timeline:
#carousel-container {
scroll-timeline: --carousel-progress inline;
}
This lets us reference --carousel-progress somewhere else. And where do we use it? On the #carousel-progress-bar, by setting its animation-timeline to that name.
#carousel-progress-bar {
animation-timeline: --carousel-progress;
}
This way, the expand-progress animation follows the scroll progress of #carousel-container, and not the root.
Now, here’s the catch, with this setup alone, it won’t work. Why? Because the progress bar isn’t a descendant of the scroller. By default, an animation-timeline is only visible to elements inside the scroller’s DOM tree. That’s where timeline-scope comes in.
We can extend the scope so the progress bar is included, even though it’s outside the scroll container.
To do this we set timeline-scope: --carousel-progress; to the #carousel-wrapper, their parent element. By adding this, the #carousel-wrapper acts as the expanded scope that includes both the scroller and the progress bar. And with that, the animation works as expected.
3. Carousel Progress Markers
For our next effect, we’re still tracking a carousel’s progress, but this time, we’re using markers.
The markers change based on which slide is currently in view. This introduces us to the third type of animation timeline: the view timeline progress. This type of timeline moves forward based on an element’s visibility inside a scroller. In other words, as the element enters the scroller’s scrollport, the timeline starts progressing, and it keeps going until the element leaves the scrollport.
Here’s the HTML structure:
<div id="marker-carousel-wrapper">
<div id="marker-carousel-container">
<div id="marker-carousel-item-1" className="marker-carousel-item">1</div>
<div id="marker-carousel-item-2" className="marker-carousel-item">2</div>
<div id="marker-carousel-item-3" className="marker-carousel-item">3</div>
<div id="marker-carousel-item-4" className="marker-carousel-item">4</div>
<div id="marker-carousel-item-5" className="marker-carousel-item">5</div>
</div>
<div id="marker-container">
<a href="#marker-carousel-item-1" id="marker-1" className="marker"></a>
<a href="#marker-carousel-item-2" id="marker-2" className="marker"></a>
<a href="#marker-carousel-item-3" id="marker-3" className="marker"></a>
<a href="#marker-carousel-item-4" id="marker-4" className="marker"></a>
<a href="#marker-carousel-item-5" id="marker-5" className="marker"></a>
</div>
</div>
and the css;
#marker-carousel-wrapper {
width: 324px;
margin: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0px;
timeline-scope: --marker-1, --marker-2, --marker-3, --marker-4, --marker-5; /* Custom identifier */
}
#marker-carousel-container {
height: 300px;
display: flex;
overflow-x: scroll;
padding: 0 12px;
scroll-behavior: smooth;
gap: 10px;
scroll-snap-type: x mandatory;
}
#marker-carousel-container::-webkit-scrollbar {
display: none; /*hide scrollbar*/
}
.marker-carousel-item {
background-color: var(--border-color);
scroll-snap-align: center;
display: block;
height: 100%;
min-width: 300px;
border-radius: 8px;
display: grid;
place-items: center;
}
#marker-container {
display: flex;
justify-content: center;
width: 100%;
align-items: center;
gap: 6px;
}
.marker {
width: 8px;
background: #444444;
height: 8px;
border-radius: 50%;
animation: active 1s linear forwards;
}
/* Define view-timelines for each image */
#marker-carousel-item-1 {
view-timeline: --marker-1 inline 50%;
}
#marker-carousel-item-2 {
view-timeline: --marker-2 inline 50%;
}
#marker-carousel-item-3 {
view-timeline: --marker-3 inline 50%;
}
#marker-carousel-item-4 {
view-timeline: --marker-4 inline 50%;
}
#marker-carousel-item-5 {
view-timeline: --marker-5 inline 50%;
}
/* Connect markers to their respective timelines */
#marker-1 {
animation-timeline: --marker-1;
}
#marker-2 {
animation-timeline: --marker-2;
}
#marker-3 {
animation-timeline: --marker-3;
}
#marker-4 {
animation-timeline: --marker-4;
}
#marker-5 {
animation-timeline: --marker-5;
}
@keyframes active {
0% {
background-color: #444444;
}
50% {
background-color: #f0851a;
}
100% {
background-color: #444444;
}
}
Can’t help feeling like there is a more elegant approach to this (there probably is)… but honestly, I’m not too bothered right now.
The basic idea here is that when a slide is halfway into view, its matching marker changes color. The trick is to track each slide’s visibility inside the scroller, and once 50% of it is within the scrollport, we activate its marker. Simple enough, right?
So, we start by applying a view-timeline with a custom timeline name, like this:
#marker-carousel-item-1 {
view-timeline: --marker-1 inline 50%;
}
#marker-carousel-item-2 {
view-timeline: --marker-2 inline 50%;
}
/* and so on */
We also set view-timeline-axis to inline and view-timeline-inset to 50%. This way, a slide is considered “visible” once half of it has entered the scrollport. From there, we link each marker’s animation to its matching timeline, like this:
#marker-1 {
animation-timeline: --marker-1;
}
#marker-2 {
animation-timeline: --marker-2;
}
/* and so on */
Finally given the marker-container is not a descendant of marker-carousel-container, we increase the scope to their parent component by:
#marker-carousel-container {
timeline-scope: --marker-1, --marker-2, --marker-3, --marker-4, --marker-5; /* Custom identifier */
}
And with this our effect is working.
Image Parallax
The final effect is a carousel parallax. Personally, I find this one the most visually appealing. Simple, but very satisfying to watch.
The idea is quite straightforward. As you scroll horizontally through the carousel, each image shifts its position slightly, creating that smooth parallax feel. We’ll achieve this by using a view timeline tied to each image, so the animation plays based on how much of the image is in view.
Below is the code. The html
<div id="image-scroll-parallax-container">
<div className="image-scroll-parallax-item">
<img className="image-scroll-parallax-image" src="..." alt="..." />
</div>
<div className="image-scroll-parallax-item">
<img className="image-scroll-parallax-image" src="..." alt="..." />
</div>
<div className="image-scroll-parallax-item">
<img className="image-scroll-parallax-image" src="..." alt="..." />
</div>
<div className="image-scroll-parallax-item">
<img className="image-scroll-parallax-image" src="..." alt="..." />
</div>
<div className="image-scroll-parallax-item">
<img className="image-scroll-parallax-image" src="..." alt="..." />
</div>
</div>
and the css;
#image-scroll-parallax-container {
width: 100%;
display: flex;
overflow: scroll;
white-space: no-wrap;
padding: 4px;
scroll-snap-type: x mandatory;
gap: 10px;
}
.image-scroll-parallax-item {
display: inline-block;
scroll-snap-align: center;
width: 300px;
height: 300px;
min-width: 300px;
position: relative;
overflow: clip;
}
.image-scroll-parallax-image {
width: 100%;
height: 100%;
position: absolute;
right: 0;
object-fit: cover;
object-position: 30% 0;
animation: parallax linear both;
animation-timeline: view(x);
}
@keyframes parallax {
from {
object-position: 100% 0;
}
to {
object-position: 0;
}
}
The .image-scroll-parallax-image is absolutely positioned so it can shift inside its container. The keyframes simply animate the object-position from 100%(fully shifted to one side) to 0 (centered), creating the parallax movement. By tying this animation to a view timeline with animation-timeline: view(x), the animation plays as the image enters and exits the horizontal scrollport.
Winding up
Scroll-driven animations, when done right, just have that wow factor. And the fact that you can pull them off with pure CSS makes it even better. You can go from simple scroll spies to pinned horizontal sections and fancy grids, even bring 3D models to life, all without touching a single line of JavaScript. The possibilities feel endless, and honestly, it’s kind of wild how much you can do.