How to Create Scroll-Driven Animations
You can now build immersive scroll effects using only CSS.
Scroll animations have historically been a significant pain point on the web. To do them right, you typically needed JavaScript to attach heavy scroll event listeners that risked blocking the main thread, or set up complicated IntersectionObserver logic to track elements entering or leaving the viewport.
Even with the best JavaScript implementations, syncing animations perfectly to the scroll position was very difficult to do, especially on mobile devices.
But that is changing. With the new Scroll-Driven Animations API, we can now link CSS animations directly to a scroll container or the position of an element within the viewport. This means the browser handles the animation on the compositor thread, resulting in extremely smooth 60fps scroll-based experiences without any JavaScript at all.
This is great because:
- These animations run off the main thread. Even if your site is loading heavy JavaScript used for other things, the scroll animations themselves will remain smooth.
- The declarative nature of CSS makes the animation code easier to read and maintain than imperative JavaScript code.
- No large JavaScript libraries are necessary.
In this article we'll cover three examples of how to use view-timeline to create effects that previously required JavaScript to accomplish.
For more examples on this topic, be sure to check out https://scroll-driven-animations.style.
Note that at the time of this writing, Firefox does not yet support the scroll-driven animations API.
Getting Started
Before we jump into the code, it's important to understand the two main types of timelines this API supports.
Scroll Timeline - scroll()
Linked to the scroll position of a scroll container (like the main page body or a div with overflow: scroll). It goes from 0% to 100% as you scroll from top to bottom of the container.
View Timeline - view()
Linked to the visibility of a specific element within the scrollport. It tracks that element as it enters, covers, and exits the viewport.
For the purposes of this article, all three examples focus on view-timeline specifically.
To keep the article shorter, some of the CSS is omitted. Please reference the demo for the full code.
Example 1 - Animated Card Stacking
For the first example, we’ll create a classic "stacking cards" effect. You’ve likely seen this animation style before because it’s fairly common. As the user scrolls, the cards stick to an anchor point on the screen and continue to stack.
Our example has an additional scale and fade out animation to make room for the next card in the series, creating a sense of depth. It's based on the Get Hyped homepage.
Starting with the HTML structure, we have a container with several card elements inside.
<section class="cards">
<div class="card card-1">
<div class="card-inner">
<!-- Card Content -->
</div>
</div>
<div class="card card-2">
<div class="card-inner">
<!-- Card Content -->
</div>
</div>
<!-- More cards... -->
</section>
In our CSS, we define a named timeline on the container using view-timeline-name. This tells the browser to track the .cards element position in the viewport. We'll use this timeline to drive the animations of the cards inside it.
.cards {
--numberOfCards: 4;
/* Define the timeline scope */
view-timeline-name: --cards;
}
Now for some basic card styling. Each one is assigned a CSS variable for index and rotate - we’ll go through those in a minute.
.card-1 {
--index: 1;
--rotate: -5deg;
.card-inner {
background: #e3e3fc;
}
}
.card-2 {
--index: 2;
--rotate: 5deg;
.card-inner {
background: #bdd5ea;
}
}
...additional cards
Next, we apply position: sticky to the cards. This is what physically stacks them on top of each other as you scroll. Without it, you would just scroll past them normally.
Notice the use of @supports. Because this is a newer browser API, it does not have full support yet. This ensures that browsers without support can have fallback styles.
.card {
position: sticky;
top: 8%;
margin-bottom: 100px;
perspective: 70vh; /* Adds some 3D depth for the rotation */
@supports (animation-timeline: view()) {
--index0: calc(var(--index) - 1);
--reverse-index: calc(var(--numberOfCards) - var(--index0));
--reverse-index0: calc(var(--reverse-index) - 1);
}
}
We set variables for --numberOfCards , --index and --rotate above for each card. Now we use those variables to derive some additional values:
--index0= zero-based index (e.g. card 2 - 1)--reverse-index/--reverse-index0= the index counted from the end--start-range/--end-range= The % of the timeline a card should animate. For example, card 1 from "0% - 25%", card 2 from 25% - 50%, card 3 from 50% - 75%, card 4 from 75% - 100%.
The exit-crossing keyword in animation-range is important here. This is what takes in the start/end range variables. By calculating different start and end points for each card, we can create a seamless sequence.
Next, we add the --cards timeline defined earlier to the .card-inner div.
.card-inner {
@supports (animation-timeline: view()) {
--start-range: calc(var(--index0) / var(--numberOfCards) * 100%);
--end-range: calc((var(--index)) / var(--numberOfCards) * 100%);
animation: linear scale forwards;
/* The animation-timeline property specifies the timeline that the animation should run on.
It needs to be set after the animation property. */
animation-timeline: --cards;
/* Calculate when this specific card should animate */
animation-range: exit-crossing var(--start-range) exit-crossing
var(--end-range);
}
will-change: transform;
transform-origin: 50% 10%;
overflow: hidden;
padding: 100px 40px;
border-radius: 30px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80vh;
transform-origin: center;
}
Finally, add the animation keyframe that will drive the exit animation.
@keyframes scale {
100% {
transform: rotate(var(--rotate)) rotateX(30deg) scale(0.5);
opacity: 0;
}
}
Example 2 - Rotating Dial
The next example is a unique one - a dial that rotates as you scroll past it. This demonstrates how a simple scroll interaction can add a nice touch of polish to a UI without being overwhelming, as well as how precise we can be because we're just using CSS. This particular example is based on the interaction seen on the Frigade website.
Please note that this specific example is not styled well for smaller devices. Additional CSS is needed to cover all device sizes.
The HTML consists of a wrapper and the compass arrows.
<section class="compass">
<div class="compass-inner">
<div class="compass-arrow-top"></div>
<div class="compass-arrow-bottom">
<svg width="100%" height="100%" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="48"
fill="none"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth="1.5"
strokeDasharray="0.5 2"
/>
</svg>
</div>
</div>
</section>
Just like before, we’ll need to define a timeline on the parent container.
.compass {
/* Define the timeline scope */
view-timeline-name: --compass;
height: 100vh;
}
.compass-inner {
width: 100%;
height: 500px;
background: #222127;
border-radius: 30px;
overflow: hidden;
position: relative;
}
Then, we apply the animation to each of the arrows. We want the top arrow to rotate clockwise and the bottom one to rotate counter-clockwise. We do this by linking the animation-timeline we created and assigning an animation-range and animation keyframe for each.
.compass-arrow-top {
@supports (animation-timeline: view()) {
animation: rotateCompassClockwise linear both;
/* Link the timeline scope */
animation-timeline: --compass;
animation-range: entry 0% exit 0%;
}
...additional styles
}
@keyframes rotateCompassClockwise {
from { transform: rotate(100deg); }
to { transform: rotate(0deg); }
}
.compass-arrow-bottom {
@supports (animation-timeline: view()) {
animation: rotateCompassCounterClockwise linear both;
/* Link the timeline scope */
animation-timeline: --compass;
animation-range: entry 0% exit 0%;
}
...additional styles
}
@keyframes rotateCompassCounterClockwise {
from { transform: rotate(-100deg); }
to { transform: rotate(0deg); }
}
The animation-range: entry 0% exit 0% value here is a bit different from our first example. This one means "start the animation the moment the element starts entering the viewport, and finish it the moment it starts exiting." Since the container is 100vh tall, this covers the entire time the compass is centered on screen.
This creates a direct 1:1 connection between the user's scroll position and the rotation of the compass.
When building animations like this, it's helpful to experiment with a wide range of animation-range values to see what works best for your scenario.Example 3 - Photo Grid Animation
For our final example, we’ll create a dynamic animation for a grid of photos. They start in a fanned pile and then move outwards into a grid as the user scrolls through the parent container.
The demo for this example contains a bit of extra code to support a basic grid layout for browsers that don't support view().The HTML for this is not too different from the previous examples.
<section class="photos">
<div class="photos-inner">
<div class="photo photo-1">
<img src="/images/image1.jpg" alt="photo 1" />
</div>
<div class="photo photo-2">
<img src="/images/image1.jpg" alt="photo 2" />
</div>
<!-- More photos... -->
</div>
</section>
We define the timeline on the .photos container, which we've given a large height (300vh) to create plenty of room for the animation. The .photos-inner container is sticky, so the photos stay centered while we scroll through the defined height.
.photos {
position: relative;
view-timeline-name: --photos;
@supports (animation-timeline: view()) {
height: 300vh;
}
}
.photos-inner {
display: grid;
place-items: center;
@supports (animation-timeline: view()) {
position: sticky;
top: 0;
height: 100vh;
}
}
For the animation, we define a keyframe that will move the photos from the initial state into the grid layout.
@keyframes photos {
100% {
transform: scale(0.3) translate(var(--coords)) rotate(-360deg);
}
}
Just like in the previous examples, we need to link the timeline scope, as well as apply some additional styles like the keyframe and animation-range.
.photo {
border-radius: 30px;
overflow: hidden;
img {
max-width: 250px;
height: auto;
}
@supports (animation-timeline: view()) {
grid-area: 1 / 1;
will-change: transform;
animation: linear photos forwards;
/* Link the timeline scope */
animation-timeline: --photos;
animation-range: entry 0% exit 0%;
img {
max-width: 100%;
}
}
}
Each photo has a set of coordinates that determine the end state once the container has been scrolled through. The values are somewhat arbitrary.
.photo-1 {
--coords: 80%, 80%;
@supports (animation-timeline: view()) {
transform: rotate(-10deg);
}
}
.photo-2 {
--coords: -80%, 80%;
@supports (animation-timeline: view()) {
transform: rotate(10deg);
}
}
.photo-3 {
--coords: 80%, -80%;
@supports (animation-timeline: view()) {
transform: rotate(-20deg);
}
}
.photo-4 {
--coords: -80%, -80%;
@supports (animation-timeline: view()) {
transform: rotate(20deg);
}
}
Because we’re using animation-timeline, the browser automatically interpolates the keyframes based on the scroll position. If the user scrolls halfway and stops, the photos stop moving. This level of control allows you to do some pretty cool things, and it used to require complex JavaScript to do so. But now it's just a few lines of CSS!
Summary
The Scroll-driven animations API, and specifically view-timeline, opens up a new world of possibilities for CSS-only interactions. We can now build interesting "scrollytelling" experiences, parallax effects, and much more, all performant by default, without any JavaScript at all.