I’m utterly behind in learning about scroll-driven animations apart from the “reading progress bar” experiments all over CodePen. Well, I’m not exactly “green” on the topic; we’ve published a handful of articles on it including this neat-o one by Lee Meyer published the other week.
Our “oldest” article about the feature is by Bramus, dated back to July 2021. We were calling it “scroll-linked” animation back then. I specifically mention Bramus because there’s no one else working as hard as he is to discover practical use cases where scroll-driven animations shine while helping everyone understand the concept. He writes about it exhaustively on his personal blog in addition to writing the Chrome for Developers documentation on it.
But there’s also this free course he calls “Unleash the Power of Scroll-Driven Animations” published on YouTube as a series of 10 short videos. I decided it was high time to sit, watch, and learn from one of the best. These are my notes from it.
- A scroll-driven animation is an animation that responds to scrolling. There’s a direct link between scrolling progress and the animation’s progress.
- Scroll-driven animations are different than scroll-triggered animations, which execute on scroll and run in their entirety. Scroll-driven animations pause, play, and run with the direction of the scroll. It sounds to me like scroll-triggered animations are a lot like the CSS version of the JavaScript intersection observer that fires and plays independently of scroll.
- Why learn this? It’s super easy to take an existing CSS animation or a WAAPI animation and link it up to scrolling. The only “new” thing to learn is how to attach an animation to scrolling. Plus, hey, it’s the platform!
- There are also performance perks. JavsScript libraries that establish scroll-driven animations typically respond to scroll events on the main thread, which is render-blocking… and JANK! We’re working with hardware-accelerated animations… and NO JANK. Yuriko Hirota has a case study on the performance of scroll-driven animations published on the Chrome blog.
- Supported in Chrome 115+. Can use
@supports (animation-timeline: scroll())
. However, I recently saw Bramus publish an update saying we need to look for animation-range
support as well.
@supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) {
/* Scroll-Driven Animations related styles go here */
/* This check excludes Firefox Nightly which only has a partial implementation at the moment of posting (mid-September 2024). */
}
- Remember to use
prefers-reduced-motion
and be mindful of those who may not want them.
Let’s take an existing CSS animation.
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
#progress {
animation: grow-progress 2s linear forwards;
}
Translation: Start with no width and scale it to its full width. When applied, it takes two seconds to complete and moves with linear easing just in the forwards
direction.
This just runs when the #progress
element is rendered. Let’s attach it to scrolling.
animation-timeline
: The timeline that controls the animation’s progress. scroll()
: Creates a new scroll timeline set up to track the nearest ancestor scroller in the block direction.
#progress {
animation: grow-progress 2s linear forwards;
animation-timeline: scroll();
}
That’s it! We’re linked up. Now we can remove the animation-duration
value from the mix (or set it to auto
):
#progress {
animation: grow-progress linear forwards;
animation-timeline: scroll();
}
Note that we’re unable to plop the animation-timeline
property on the animation
shorthand, at least for now. Bramus calls it a “reset-only sub-property of the shorthand” which is a new term to me. Its value gets reset when you use the shorthand the same way background-color
is reset by background
. That means the best practice is to declare animation-timeline
after animation
.
/* YEP! */
#progress {
animation: grow-progress linear forwards;
animation-timeline: scroll();
}
/* NOPE! */
#progress {
animation-timeline: scroll();
animation: grow-progress linear forwards;
}
Let’s talk about the scroll()
function. It creates an anonymous scroll timeline that “walks up” the ancestor tree from the target element to the nearest ancestor scroll. In this example, the nearest ancestor scroll is the :root
element, which is tracked in the block direction.
We can name scroll timelines, but that’s in another video. For now, know that we can adjust which axis to track and which scroller to target in the scroll()
function.
animation-timeline: scroll(<axis> <scroller>);
<axis>
: The axis — be it block
(default), inline
, y
, or x
. <scroller>
: The scroll container element that defines the scroll position that influences the timeline’s progress, which can be nearest
(default), root
(the document), or self
.
If the root element does not have an overflow, then the animation becomes inactive. WAAPI gives us a way to establish scroll timelines in JavaScript with ScrollTimeline
.
const $progressbar = document.querySelector(#progress);
$progressbar.style.transformOrigin = '0% 50%';
$progressbar.animate(
{
transform: ['scaleX(0)', 'scaleY()'],
},
{
fill: 'forwards',
timeline: new ScrollTimeline({
source: document.documentElement, // root element
// can control `axis` here as well
}),
}
)
First, we oughta distinguish a scroll container from a scroll port. Overflow can be visible or clipped. Clipped could be scrolling.
Those two bordered boxes show how easy it is to conflate scrollports and scroll containers. The scrollport is the visible part and coincides with the scroll container’s padding-box
. When a scrollbar is present, that plus the scroll container is the root scroller, or the scroll container.
A view timeline tracks the relative position of a subject within a scrollport. Now we’re getting into IntersectionObserver
territory! So, for example, we can begin an animation on the scroll timeline when an element intersects with another, such as the target element intersecting the viewport, then it progresses with scrolling.
Bramus walks through an example of animating images in long-form content when they intersect with the viewport. First, a CSS animation to reveal an image from zero opacity to full opacity (with some added clipping).
@keyframes reveal {
from {
opacity: 0;
clip-path: inset(45% 20% 45% 20%);
}
to {
opacity: 1;
clip-path: inset(0% 0% 0% 0%);
}
}
.revealing-image {
animation: reveal 1s linear both;
}
This currently runs on the document’s timeline. In the last video, we used scroll()
to register a scroll timeline. Now, let’s use the view()
function to register a view timeline instead. This way, we’re responding to when a .revealing-image
element is in, well, view.
.revealing-image {
animation: reveal 1s linear both;
/* Rember to declare the timeline after the shorthand */
animation-timeline: view();
}
At this point, however, the animation is nice but only completes when the element fully exits the viewport, meaning we don’t get to see the entire thing. There’s a recommended way to fix this that Bramus will cover in another video. For now, we’re speeding up the keyframes instead by completing the animation at the 50%
mark.
@keyframes reveal {
from {
opacity: 0;
clip-path: inset(45% 20% 45% 20%);
}
50% {
opacity: 1;
clip-path: inset(0% 0% 0% 0%);
}
}
More on the view()
function:
animation-timeline: view(<axis> <view-timeline-inset>);
We know <axis>
from the scroll()
function — it’s the same deal. The <view-timeline-inset>
is a way of adjusting the visibility range of the view progress (what a mouthful!) that we can set to auto
(default) or a <length-percentage>
. A positive inset moves in an outward adjustment while a negative value moves in an inward adjustment. And notice that there is no <scroller>
argument — a view timeline always tracks its subject’s nearest ancestor scroll container.
OK, moving on to adjusting things with ViewTimeline
in JavaScript instead.
const $images = document.querySelectorAll(.revealing-image);
$images.forEach(($image) => {
$image.animate(
[
{ opacity: 0, clipPath: 'inset(45% 20% 45% 20%)', offset: 0 }
{ opacity: 1; clipPath: 'inset(0% 0% 0% 0%)', offset: 0.5 }
],
{
fill: 'both',
timeline: new ViewTimeline({
subject: $image,
axis: 'block', // Do we have to do this if it's the default?
}),
}
}
)
This has the same effect as the CSS-only approach with animation-timeline
.
Last time, we adjusted where the image’s reveal
animation ends by tweaking the keyframes to end at 50%
rather than 100%
. We could have played with the inset()
. But there is an easier way: adjust the animation attachment range,
Most scroll animations go from zero scroll to 100% scroll. The animation-range
property adjusts that:
animation-range: normal normal;
Those two values: the start scroll and end scroll, default:
animation-range: 0% 100%;
Other length units, of course:
animation-range: 100px 80vh;
The example we’re looking at is a “full-height cover card to fixed header”. Mouthful! But it’s neat, going from an immersive full-page header to a thin, fixed header while scrolling down the page.
@keyframes sticky-header {
from {
background-position: 50% 0;
height: 100vh;
font-size: calc(4vw + 1em);
}
to {
background-position: 50% 100%;
height: 10vh;
font-size: calc(4vw + 1em);
background-color: #0b1584;
}
}
If we run the animation during scroll, it takes the full animation range, 0%-100%.
.sticky-header {
position: fixed;
top: 0;
animation: sticky-header linear forwards;
animation-timeline: scroll();
}
Like the revealing images from the last video, we want the animation range a little narrower to prevent the header from animating out of view. Last time, we adjusted the keyframes. This time, we’re going with the property approach:
.sticky-header {
position: fixed;
top: 0;
animation: sticky-header linear forwards;
animation-timeline: scroll();
animation-range: 0vh 90vh;
}
We had to subtract the full height (100vh
) from the header’s eventual height (10vh
) to get that 90vh
value. I can’t believe this is happening in CSS and not JavaScript! Bramus sagely notes that font-size
animation happens on the main thread — it is not hardware-accelerated — and the entire scroll-driven animation runs on the main as a result. Other properties cause this as well, notably custom properties.
Back to the animation range. It can be diagrammed like this:
Notice that there are four points in there. We’ve only been chatting about the “start edge” and “end edge” up to this point, but the range covers a larger area in view timelines. So, this:
animation-range: 0% 100%; /* same as 'normal normal' */
…to this:
animation-range: cover 0% cover 100%; /* 'cover normal cover normal' */
…which is really this:
animation-range: cover;
So, yeah. That revealing image animation from the last video? We could have done this, rather than fuss with the keyframes or insets:
animation-range: cover 0% cover 50%;
So nice. The demo visualization is hosted at scroll-driven-animations.style
. Oh, and we have keyword values available: contain
, entry
, exit
, entry-crossing
, and exit-crossing
.
The examples so far are based on the scroller being the root element. What about ranges that are taller than the scrollport subject? The ranges become slightly different.
This is where the entry-crossing
and entry-exit
values come into play. This is a little mind-bendy at first, but I’m sure it’ll get easier with use. It’s clear things can get complex really quickly… which is especially true when we start working with multiple scroll-driven animation with their own animation ranges. Yes, that’s all possible. It’s all good as long as the ranges don’t overlap. Bramus uses a contact list demo where contact items animate when they enter and exit the scrollport.
@keyframes animate-in {
0% { opacity: 0; transform: translateY: 100%; }
100% { opacity: 1; transform: translateY: 0%; }
}
@keyframes animate-out {
0% { opacity: 1; transform: translateY: 0%; }
100% { opacity: 0; transform: translateY: 100%; }
}
.list-view li {
animation: animate-in linear forwards,
animate-out linear forwards;
animation-timeline: view();
animation-range: entry, exit; /* animation-in, animation-out */
}
Another way, using entry
and exit
keywords directly in the keyframes:
@keyframes animate-in {
entry 0% { opacity: 0; transform: translateY: 100%; }
entry 100% { opacity: 1; transform: translateY: 0%; }
}
@keyframes animate-out {
exit 0% { opacity: 1; transform: translateY: 0%; }
exit 100% { opacity: 0; transform: translateY: 100%; }
}
.list-view li {
animation: animate-in linear forwards,
animate-out linear forwards;
animation-timeline: view();
}
Notice that animation-range
is no longer needed since its values are declared in the keyframes. Wow.
OK, ranges in JavaScript.:
const timeline = new ViewTimeline({
subjext: $li,
axis: 'block',
})
// Animate in
$li.animate({
opacity: [ 0, 1 ],
transform: [ 'translateY(100%)', 'translateY(0)' ],
}, {
fill: 'forwards',
// One timeline instance with multiple ranges
timeline,
rangeStart: 'entry: 0%',
rangeEnd: 'entry 100%',
})
This time, we’re learning how to attach an animation to any scroll container on the page without needing to be an ancestor of that element. That’s all about named timelines.
But first, anonymous timelines track their nearest ancestor scroll container.
<html> <!-- scroll -->
<body>
<div class="wrapper">
<div style="animation-timeline: scroll();"></div>
</div>
</body>
</html>
Some problems might happen like when overflow is hidden from a container:
<html> <!-- scroll -->
<body>
<div class="wrapper" style="overflow: hidden;"> <!-- scroll -->
<div style="animation-timeline: scroll();"></div>
</div>
</body>
</html>
Hiding overflow means that the element’s content block is clipped to its padding box and does not provide any scrolling interface. However, the content must still be scrollable programmatically meaning this is still a scroll container. That’s an easy gotcha if there ever was one! The better route is to use overflow: clipped
rather than hidden
because that prevents the element from becoming a scroll container.
Hiding oveflow = scroll container. Clipping overflow = no scroll container. Bramus says he no longer sees any need to use overflow: hidden
these days unless you explicitly need to set a scroll container. I might need to change my muscle memory to make that my go-to for hiding clipping overflow.
Another funky thing to watch for: absolute positioning on a scroll animation target in a relatively-positioned container. It will never match an outside scroll container that is scroll(inline-nearest)
since it is absolute to its container like it’s unable to see out of it.
We don’t have to rely on the “nearest” scroll container or fuss with different overflow
values. We can set which container to track with named timelines.
.gallery {
position: relative;
}
.gallery__scrollcontainer {
overflow-x: scroll;
scroll-timeline-name: --gallery__scrollcontainer;
scroll-timeline-axis: inline; /* container scrolls in the inline direction */
}
.gallery__progress {
position: absolute;
animation: progress linear forwards;
animation-timeline: scroll(inline nearest);
}
We can shorten that up with the scroll-timeline
shorthand:
.gallery {
position: relative;
}
.gallery__scrollcontainer {
overflow-x: scroll;
scroll-timeline: --gallery__scrollcontainer inline;
}
.gallery__progress {
position: absolute;
animation: progress linear forwards;
animation-timeline: scroll(inline nearest);
}
Note that block
is the scroll-timeline-axis
initial value. Also, note that the named timeline is a dashed-ident, so it looks like a CSS variable.
That’s named scroll timelines. The same is true of named view timlines.
.scroll-container {
view-timeline-name: --card;
view-timeline-axis: inline;
view-timeline-inset: auto;
/* view-timeline: --card inline auto */
}
Bramus showed a demo that recreates Apple’s old cover-flow pattern. It runs two animations, one for rotating images and one for setting an image’s z-index
. We can attach both animations to the same view timeline. So, we go from tracking the nearest scroll container for each element in the scroll:
.covers li {
view-timeline-name: --li-in-and-out-of-view;
view-timeline-axis: inline;
animation: adjust-z-index linear both;
animation-timeline: view(inline);
}
.cards li > img {
animation: rotate-cover linear both;
animation-timeline: view(inline);
}
…and simply reference the same named timelines:
.covers li {
view-timeline-name: --li-in-and-out-of-view;
view-timeline-axis: inline;
animation: adjust-z-index linear both;
animation-timeline: --li-in-and-out-of-view;;
}
.cards li > img {
animation: rotate-cover linear both;
animation-timeline: --li-in-and-out-of-view;;
}
In this specific demo, the images rotate and scale but the updated sizing does not affect the view timeline: it stays the same size, respecting the original box size rather than flexing with the changes.
Phew, we have another tool for attaching animations to timelines that are not direct ancestors: timeline-scope
.
timeline-scope: --example;
This goes on an parent element that is shared by both the animated target and the animated timeline. This way, we can still attach them even if they are not direct ancestors.
<div style="timeline-scope: --gallery">
<div style="scroll-timeline: --gallery-inline;">
...
</div>
<div style="animation-timeline: --gallery;"></div>
</div>
It accepts multiple comma-separated values:
timeline-scope: --one, --two, --three;
/* or */
timeline-scope: all; /* Chrome 116+ */
There’s no Safari or Firefox support for the all
kewword just yet but we can watch for it at Caniuse (or the newer BCD Watch!).
This video is considered the last one in the series of “core concepts.” The next five are more focused on use cases and examples.
In this example, we’re conditionally showing scroll shadows on a scroll container. Chris calls scroll shadows one his favorite CSS-Tricks of all time and we can nail them with scroll animations.
Here is the demo Chris put together a few years ago:
That relies on having a background with multiple CSS gradients that are pinned to the extremes with background-attachment: fixed
on a single selector. Let’s modernize this, starting with a different approach using pseudos with sticky positioning:
.container::before,
.container::after {
content: "";
display: block;
position: sticky;
left: 0em;
right 0em;
height: 0.75rem;
&::before {
top: 0;
background: radial-gradient(...);
}
&::after {
bottom: 0;
background: radial-gradient(...);
}
}
The shadows fade in and out with a CSS animation:
@keyframes reveal {
0% { opacity: 0; }
100% { opacity: 1; }
}
.container {
overflow:-y auto;
scroll-timeline: --scroll-timeline block; /* do we need `block`? */
&::before,
&::after {
animation: reveal linear both;
animation-timeline: --scroll-timeline;
}
}
This example rocks a named timeline, but Bramus notes that an anonymous one would work here as well. Seems like anonymous timelines are somewhat fragile and named timelines are a good defensive strategy.
The next thing we need is to set the animation’s range so that each pseudo scrolls in where needed. Calculating the range from the top is fairly straightforward:
.container::before {
animation-range: 1em 2em;
}
The bottom is a little tricker. It should start when there are 2em
of scrolling and then only travel for 1em
. We can simply reverse the animation and add a little calculation to set the range based on it’s bottom edge.
.container::after {
animation-direction: reverse;
animation-range: calc(100% - 2em) calc(100% - 1em);
}
Still one more thing. We only want the shadows to reveal when we’re in a scroll container. If, for example, the box is taller than the content, there is no scrolling, yet we get both shadows.
This is where the conditional part comes in. We can detect whether an element is scrollable and react to it. Bramus is talking about an animation
keyword that’s new to me: detect-scroll.
@keyframes detect-scroll {
from,
to {
--can-scroll: ; /* value is a single space and acts as boolean */
}
}
.container {
animation: detect-scroll;
animation-timeline: --scroll-timeline;
animation-fill-mode: none;
}
Gonna have to wrap my head around this… but the general idea is that --can-scroll
is a boolean value we can use to set visibility on the pseudos:
.content::before,
.content::after {
--vis-if-can-scroll: var(--can-scroll) visible;
--vis-if-cant-scroll: hidden;
visibility: var(--vis-if-can-scroll, var(--vis-if-cant-scroll));
}
Bramus points to this CSS-Tricks article for more on the conditional toggle stuff.
This should be fun! Let’s say we have a set of columns:
<div class="columns">
<div class="column reverse">...</div>
<div class="column">...</div>
<div class="column reverse">...</div>
</div>
The goal is getting the two outer reverse
columns to scroll in the opposite direction as the inner column scrolls in the other direction. Classic JavaScript territory!
The columns are set up in a grid container. The columns flex in the column
direction.
/* run if the browser supports it */
@supports (animation-timeline: scroll()) {
.column-reverse {
transform: translateY(calc(-100% + 100vh));
flex-direction: column-reverse; /* flows in reverse order */
}
.columns {
overflow-y: clip; /* not a scroll container! */
}
}
First, the outer columns are pushed all the way up so the bottom edges are aligned with the viewport’s top edge. Then, on scroll, the outer columns slide down until their top edges re aligned with the viewport’s bottom edge.
The CSS animation:
@keyframes adjust-position {
from /* the top */ {
transform: translateY(calc(-100% + 100vh));
}
to /* the bottom */ {
transform: translateY(calc(100% - 100vh));
}
}
.column-reverse {
animation: adjust-position linear forwards;
animation-timeline: scroll(root block); /* viewport in block direction */
}
The approach is similar in JavaScript:
const timeline = new ScrollTimeline({
source: document.documentElement,
});
document.querySelectorAll(".column-reverse").forEach($column) => {
$column.animate(
{
transform: [
"translateY(calc(-100% + 100vh))",
"translateY(calc(100% - 100vh))"
]
},
{
fill: "both",
timeline,
}
);
}
This one’s working with a custom element for a 3D model:
<model-viewer alt="Robot" src="robot.glb"></model-viewer>
First, the scroll-driven animation. We’re attaching an animation to the component but not defining the keyframes just yet.
@keyframes foo {
}
model-viewer {
animation: foo linear both;
animation-timeline: scroll(block root); /* root scroller in block direction */
}
There’s some JavaScript for the full rotation and orientation:
// Bramus made a little helper for handling the requested animation frames
import { trackProgress } from "https://esm.sh/@bramus/sda-utilities";
// Select the component
const $model = document.QuerySelector("model-viewer");
// Animation begins with the first iteration
const animation = $model.getAnimations()[0];
// Variable to get the animation's timing info
let progress = animation.effect.getComputedTiming().progress * 1;
// If when finished, $progress = 1
if (animation.playState === "finished") progress = 1;
progress = Math.max(0.0, Math.min(1.0, progress)).toFixed(2);
// Convert this to degrees
$model.orientation = `0deg 0deg $(progress * -360)deg`;
We’re using the effect to get the animation’s progress rather than the current timed spot. The current time value is always measured relative to the full range, so we need the effect to get the progress based on the applied animation.
The video description is helpful:
Bramus goes full experimental and uses Scroll-Driven Animations to detect the active scroll speed and the directionality of scroll. Detecting this allows you to style an element based on whether the user is scrolling (or not scrolling), the direction they are scrolling in, and the speed they are scrolling with … and this all using only CSS.
First off, this is a hack. What we’re looking at is expermental and not very performant. We want to detect the animations’s velocity and direction. We start with two custom properties.
@keyframes adjust-pos {
from {
--scroll-position: 0;
--scroll-position-delayed: 0;
}
to {
--scroll-position: 1;
--scroll-position-delayed: 1;
}
}
:root {
animation: adjust-pos linear both;
animation-timeline: scroll(root);
}
Let’s register those custom properties so we can interpolate the values:
@property --scroll-position {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@property --scroll-position-delayed {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
As we scroll, those values change. If we add a little delay, then we can stagger things a bit:
:root {
animation: adjust-pos linear both;
animation-timeline: scroll(root);
}
body {
transition: --scroll-position-delayed 0.15s linear;
}
The fact that we’re applying this to the body
is part of the trick because it depends on the parent-child relationship between html
and body
. The parent element updates the values immediately while the child lags behind just a tad. The evaluate to the same value, but one is slower to start.
We can use the difference between the two values as they are staggered to get the velocity.
:root {
animation: adjust-pos linear both;
animation-timeline: scroll(root);
}
body {
transition: --scroll-position-delayed 0.15s linear;
--scroll-velocity: calc(
var(--scroll-position) - var(--scroll-position-delayed)
);
}
Clever! If --scroll-velocity
is equal to 0
, then we know that the user is not scrolling because the two values are in sync. A positive number indicates the scroll direction is down, while a negative number indicates scrolling up,.
There’s a little discrepancy when scrolling abruptly changes direction. We can fix this by tighening the transition delay of --scroll-position-delayed
but then we’re increasing the velocity. We might need a multiplier to further correct that… that’s why this is a hack. But now we have a way to sniff the scrolling speed and direction!
Here’s the hack using math functions:
body {
transition: --scroll-position-delayed 0.15s linear;
--scroll-velocity: calc(
var(--scroll-position) - var(--scroll-position-delayed)
);
--scroll-direction: sign(var(--scroll-velocity));
--scroll-speed: abs(var(--scroll-velocity));
}
This is a little funny because I’m seeing that Chrome does not yet support sign()
or abs()
, at least at the time I’m watching this. Gotta enable chrome://flags
. There’s a polyfill for the math brought to you by Ana Tudor right here on CSS-Tricks.
So, now we could theoretically do something like skew an element by a certain amount or give it a certain level of background color saturation depending on the scroll speed.
.box {
transform: skew(calc(var(--scroll-velocity) * -25deg));
transition: background 0.15s ease;
background: hsl(
calc(0deg + (145deg * var(--scroll-direction))) 50 % 50%
);
}
We could do all this with style queries should we want to:
@container style(--scroll-direction: 0) { /* idle */
.slider-item {
background: crimson;
}
}
@container style(--scroll-direction: 1) { /* scrolling down */
.slider-item {
background: forestgreen;
}
}
@container style(--scroll-direction: -1) { /* scrolling down */
.slider-item {
background: lightskyblue;
}
}
Custom properties, scroll-driven animations, and style queries — all in one demo! These are wild times for CSS, tell ya what.
The tenth and final video! Just a summary of the series, so no new notes here. But here’s a great demo to cap it off.