🚨 UPDATE: The Scroll-Linked Animations Specification and its proposed syntax have undergone a major rewrite. This post details an older version of the syntax and has not been updated to reflect these changes.
Do note that the concept of a Scroll-Linked Animation still stands, it’s only the syntax that has changed since writing this. Please refer to https://developer.chrome.com/articles/scroll-driven-animations/ for an article with examples that use the updated syntax.
Example of what is possible with Scroll-Linked Animations, using only CSS
The Scroll-linked Animations Specification is an upcoming addition to CSS that defines a way for creating animations that are linked to a scroll offset of a scroll container. Even though the specification is still in draft, and in no way finalized nor official, it already has experimental support in Chromium.
The past few weeks I’ve been playing with the CSS @scroll-timeline
at-rule and the animation-timeline
CSS property this specification provides. By combining these two features with regular CSS Animations we can create Scroll-Linked Animations using only CSS — not a single line of JavaScript in sight!
In this first part of this series we’ll take a look at Scroll-Linked Animations between two absolute scroll-offsets, and how we can tweak them. In the second part of this series (published here) we’ll cover how to create Scroll-Linked Animations based on the location of an element within the scroller.
~
👨🔬 The CSS features described in this post are still experimental and not finalized at all! If you’re feeling adventurous you can play with these new features today, but you’ll need at least Chromium 89 with the #experimental-web-platform-features
flag enabled through chrome://flags
.
💥 To keep your primary Chrome install clean, I recommend you do not set this in Chrome Stable, but resort to Beta / Canary builds.
👀 If you don’t understand how to do this, or don’t feel safe doing this, fear not: This post also includes recordings and/or fallback versions using JavaScript for most of the demos.
💄 While the Scroll-Linked Animations Specification also describes a JavaScript interface, the main focus of this post will be its CSS counterpart. The JS alternatives won’t be covered in detail.
~
Table of Contents
~
# Primer: Scroll-Linked Animations vs. Scroll-Triggered Animations
Before we jump into the CSS code, there’s this difference that we need to make between Scroll-Linked Animations and Scroll-Triggered Animations
Scroll-Linked Animations are animations are linked to the scroll offset of a scroll container. As you scroll back and forth the scroll container, you will see the animation timeline advance or rewind as you do so. If you stop scrolling, the animation will also stop.
Think of a progress bar shown on top of a page, where there is a direct link between the scroll progress and size of the progress bar. Hit the ⏮ and ⏭ buttons in the visualization below to see how it behaves.
See the Pen Scroll-Linked Animations Visualization: Progressbar by Bramus (@bramus) on CodePen.
Scroll-Triggered Animations are animations that are triggered when scrolling past a certain position. Once triggered, these animations start and finish on their own, independent of whether you keep scrolling or not.
Think of those typical “content flies in as it enters the viewport” animations. Hit the ⏮ and ⏭ buttons in the visualization below to see how it behaves.
See the Pen Scroll-Triggered Animations Visualization: Fly-In Content by Bramus (@bramus) on CodePen.
~
# Your first Scroll-Linked Animation (Progress Bar Demo)
Instead of getting technical straight away, let’s take a look at a Progress Bar that is implemented using Scroll-Linked Animations, and dissect it from there.
See the Pen Scroll-Linked Animations: Progress Bar (@scroll-timeline version) by Bramus (@bramus) on CodePen.
See the Pen Scroll-Linked Animations: Progress Bar (WAAPI version) by Bramus (@bramus) on CodePen.
What you see there — if your browser supports it — is a scrollbar that progresses from 0 to 100% as you scroll down the page. All this is done using only CSS, and running in a non-blocking way on the compositor thread (e.g. “off main thread”)! 🤩
Apart from positioning and what not, the code that drives this demo is this little piece of CSS:
/* (1) Define Keyframes */
@keyframes adjust-progressbar {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
/* (2) Define a ScrollTimeline */
@scroll-timeline progressbar-timeline {
}
/* (3) Attach the Animation + set the ScrollTimeline as the driver for the Animation */
#progressbar {
animation: 1s linear forwards adjust-progressbar;
animation-timeline: progressbar-timeline; /* 👈 THIS! */
}
We recognise 3 key components that we need to make it all work:
~
# The Animation
This is a a regular CSS Animation. In case of our progress bar it’s an animation that goes from zero width to full width.
@keyframes adjust-progressbar {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
#progressbar {
width: 100vw;
transform: scaleX(0);
transform-origin: 0 50%;
animation: 1s linear forwards adjust-progressbar;
}
There’s a few things to note about this animation:
- To optimize this animation for the browser we don’t animate the
width
property, but fixate thewidth
to100vw
and animatetransform: scaleX(…);
instead. To make that work properly we have to set thetransform-origin
to the left edge of the element. - To prevent a FOUC we apply the start
scaleX(0);
transform directly onto the#progressbar
element. - To make sure this animation remains in its end state when it has finished, we set
animation-fill-mode
toforwards
. - The values for
animation-duration
(1s
) andanimation-timing-function
(linear
) look like they are chosen arbitrarily here, but they’re not. We’ll dig into these further down.
Now, if you implement this piece of CSS as-is, you’ll see this animation run all by itself. This is because we have not created nor linked a Scroll Timeline yet, which follow next.
~
# The Scroll Timeline
As we scroll through the document from top to bottom we want our animation to also go from start (no visible progress bar) to finish (full-width progress bar). For this we need a Scroll Timeline. It is a type of timeline that can map scroll-progression of a scroll container to animation-progress of linked animation.
To define a ScrollTimeline in CSS, we can use the new @scroll-timeline
at-rule, give it name, and configure it using descriptors:
source
orientation
scroll-offsets
For our Progress Bar our Scroll Timeline looks like this:
@scroll-timeline progress-timeline {
}
The created Scroll Timeline here has been given the name of progress-timeline
, but it hasn’t been tweaked/configured. That’s not necessary either, as it will fall back to default values for source
, orientation
, and scroll-offsets
.
By default a Scroll Timeline behaves as follows: as you scroll the document from top to bottom (e.g. from 0%
to 100%
Scroll Progress), the linked animation will also advance from 0%
to 100%
Animation Progress … which is exactly what we need for a progress bar 🙂
As our animation-duration
is set to 1s
in step 1, our scroll-distance-to-animation-progress mapping will automatically look like this:
0%
Scroll Progress equals0s
Animation Progress.100%
Scroll Progress equals1s
Animation Progress.
(All values in between are interpolated, so 50%
Scroll Progress will equal 0.5s
Animation Progress)
Update 2021.06.25: An earlier version of the Scroll-Linked Animations specification required you to define a time-range
here. This descriptor has been scrapped, and the contents of this post have been updated to reflect that. You can still find traces of it in the demos though, but you can simply ignore it.
🤔 If you’re curious about time-range
, you can click open this box to know what it did and how it worked …
The time-range
descriptor is of the CSS <time>
Data Type. It does not represent the time of a clock, but it is a number that maps Scroll Progress (or Scroll Distance) to Animation Progress. It gives an answer to the question “How much animation time should pass when we scroll from start to finish in the scroll container?”
As we have defined our animation-duration
to be 1s
from start to finish, we want our time-range
to reflect that same duration, namely 1s
: Scrolling from top to bottom (e.g. from 0%
to 100%
) should advance the animation by 1s
.
You can play with several combinations in this visualzation/tool:
See the Pen Scroll-Linked Animations: time-range helper by Bramus (@bramus) on CodePen.
🔥 TIP: Always set time-range
to the exact same time as the animation-duration
, unless you have a very good reason not to.
~
# Linking up both
To associate our @scroll-timeline
with our CSS Animation we use the new animation-timeline
CSS property, and have it refer to the timeline’s name.
#progressbar {
animation: 1s linear forwards adjust-progressbar;
animation-timeline: progressbar-timeline; /* 👈 THIS! */
}
This is the part where our animation-timing
value of linear
comes into play: it enforces a 1-on-1 mapping between Scroll Progress and Animation Progress. If we were to set our timing to something like ease-in
instead, we’d see our progress bar be too slow at the beginning and speed up towards the end as we scroll. This feels really weird to be honest.
animation-timing-function
to linear
when working with @scroll-timeline
.~
# Tweaking the Offsets (Parallax Cover Demo)
By default a @scroll-timeline
will be linked to scrolling vertically from top to bottom across the document. But what if we our animation to start/stop when having scrolled for a specific (~ fixed) distance? This is where the scroll-offsets
descriptor comes into play.
😵 As reader Patrick H Lauke points out you might want to go easy with the type of animation shown below in case visitors request so, by respecting the setting of prefers-reduced-motion
.
See the Pen Scroll-Linked Animations: Parallax Cover (@scroll-timeline version) by Bramus (@bramus) on CodePen.
See the Pen Scroll-Linked Animations: Parallax Cover (JS WAAPI + ScrollTimeline version) by Bramus (@bramus) on CodePen.
In this example we have a full-page (100vh
) parallax cover. For it to work correctly we want our animation to begin at the start of the document and to be finished after scrolling 100vh
into the document (instead of the default “100%
of the document”).
To make this happen we set our Scroll Offsets to 0
(start) and 100vh
(end). The resulting @scroll-timeline
definition looks like this:
@scroll-timeline parallax-header-timeline {
scroll-offsets: 0%, 100vh;
}
You can put any <length>
or <percentage>
Data Type in there.
☝️ In an earlier version of the spec one had to define the Scroll Offsets using start
and end
descriptors.
@scroll-timeline parallax-header-timeline {
start: 0%;
end: 100vh;
}
This is no longer the case, and one should now use the scroll-offsets
descriptor instead.
However, you might still see this older syntax in the demos as Chromium has this older version implemented and is in the process of migrating to the new scroll-offsets
syntax — Relevant Chromium Bug: 1094014
If you want, you can also put in more than two values, but note that your scroll to time mapping might become wonky. That’s because the set animation-duration
will be chunked evenly across the number of scroll-offsets
.
For example, with scroll-offsets: 0vh, 80vh, 100vh;
and a animation-duration
of 1s
for example, your scroll-time map will become this:
- At
0vh
youranimation-duration
will have advanced to0s
- At
80vh
youranimation-duration
will have advanced to0.5s
, as that80vh
is defined “halfway the array of values” - At
100vh
youranimation-duration
will have advanced to1s
scroll-offsets
, unless you have a specific reason not to.☝️ The scroll-offsets
can accept more types of values, which we will cover further down this post.
~
# Changing the Scroll Orientation
By default a @scroll-timeline
will be linked to scrolling vertically from top to bottom across the document. Using the orientation
descriptor we can change this to — for example — horizontal
.
@scroll-timeline example {
orientation: horizontal;
}
Use of the logical values inline
and block
is also allowed. Finally, there’s also auto
.
~
# Changing the Scroll Container (In-Page Gallery Demo)
By default a @scroll-timeline
will be linked to scrolling vertically from top to bottom across the document. But what if we don’t want across the document, but inside a specific element? This is where the source
descriptor comes into play.
Below is an example that contains two in-page image galleries/carousels, implemented using scroll-snapping. Each of those have a progress bar attached. To drive these progress bars we need not want to respond to scroll progress in the document, but to scrolling in their own scroll container.
See the Pen Scroll-Linked Animations: In-Page Gallery (@scroll-timeline version) by Bramus (@bramus) on CodePen.
See the Pen Scroll-Linked Animations: In-Page Gallery (WAAPI version) by Bramus (@bramus) on CodePen.
To define which scroll container a @scroll-timeline
responds to, you need set the source
descriptor, and have it target said element. To do so you can use the selector()
function as its value. That function requires an <id-selector>
, so you’ll need to give your targeted element an id
attribute value.
@scroll-timeline example {
source: selector(#foo);
}
As we have two galleries, we need to define two @scroll-timeline
instances and connect them to their proper progress bar. And since they are horizontally scrolling ones, we also need to set the orientation
descriptor correctly. Our code eventually looks like this:
<div class="gallery" id="gallery1">
<div class="gallery__progress" id="gallery1__progress"></div>
<div class="gallery__scrollcontainer" id="gallery1__scrollcontainer">
<div class="gallery__entry">
…
</div>
<div class="gallery__entry">
…
</div>
</div>
</div>
@keyframes progress {
to {
transform: scaleX(1);
}
}
/* #gallery1 */
@scroll-timeline gallery1__timeline {
source: selector(#gallery1__scrollcontainer);
orientation: horizontal;
}
#gallery1__progress {
/* We have 2 photos, with the 1st visible, so we start at 1/2 */
transform: scaleX(0.5);
animation: 1s linear forwards progress;
animation-timeline: gallery1__timeline;
}
/* #gallery2 */
@scroll-timeline gallery2__timeline {
source: selector(#gallery2__scrollcontainer);
orientation: horizontal;
}
#gallery2__progress {
/* We have 3 photos, with the 1st visible, so we start at 1/3 */
transform: scaleX(0.333);
animation: 1s linear forwards progress;
animation-timeline: gallery2__timeline;
}
😖 One thing I find pretty annoying when it comes to this selector()
function is that you must pass an id
into it. This can become pretty cumbersome: with 10 galleries on a page, you need to define 10 almost identical @scroll-timeline
s in your code. Only difference between them: the id
passed into selector()
.
I consider this to be shortcoming of the specification, and have raised an issue with the CSSWG: it would be handy if selector()
could point to the current element being animated or would accept any selector. That way you can reuse one single @scroll-timeline
on multiple elements.
Relevant CSS WG Issue: 5884
💡 If you think you would be able to dynamically set the <id-selector>
in source
by means of CSS Custom Property, don’t bother: CSS Variables cannot be used within descriptors.
~
# In-Between Summary
📝 Before we continue with the really cool stuff that’s coming up, let’s summarize what we know so far.
A Scroll Timeline is an interface that lets us map Scroll Progress to Animation Progress. You can define it in CSS using @scroll-timeline
with the following descriptors:
source
- The scrollable element whose scrolling triggers the activation and drives the progress of the timeline.
orientation
- The direction of scrolling which triggers the activation and drives the progress of the timeline.
scroll-offsets
- An array of two or more scroll offsets that constitute the in-progress intervals in which the timeline is active.
Allowed values for the descriptors:
- By default the
source
is the document’s scrolling element (value:auto
), but you can also target an element usingselector(<id-selector>)
- The
orientation
isvertical
orhorizontal
. Using logical unitsinline
andblock
is also possible. The initial value isauto
. - Typically the entries in
scroll-offsets
are lengths or percentages, but we’ll cover an extra variation in the next part
To attach a @scroll-timeline
to an animation, use the animation-timeline
property.
~
💁♂️ Like what you see so far? Happen to be conference or meetup organiser? Feel free to contact me to come speak at your event, with a talk covering the contents of this post.
~
# More Demos
As I have been playing with CSS @scroll-timeline
for nearly a month by now, I’ve been making quite a lot of demos. Here’s a fine selection relevant for this first part of this series:
- Parallax Cover to Sticky Header Demo
- Full Screen Panels with Snap Points Demo
- Full Screen Panels with Snap Points Demo, With Navigation Controls
~
# Parallax Cover to Sticky Header Demo
Building further upon the Parallax Cover from earlier on, here’s a demo that converts a full page Cover Image to a Sticky Header.
See the Pen Scroll-Linked Animations: Parallax Cover to Sticky Header (@scroll-timeline Version) by Bramus (@bramus) on CodePen.
See the Pen Scroll-Linked Animations: Parallax Cover to Sticky Header (WAAPI + ScrollTimeline Version) by Bramus (@bramus) on CodePen.
The @scroll-timeline
is exactly the same as the Parallax Cover demo, only the animation is a bit different: the color
, font-size
, and height
are also adjusted upon scrolling.
I couldn’t use position: sticky;
here though, as resizing the cover would shrink down the entire height of the document, and therefore the animation would flicker. Instead I resorted to position: fixed;
and added a margin-top
of 100vh
to the text content so that it remains visually below the cover.
~
# Full Screen Panels with Snap Points Demo
This is a small demo forked from this demo by Adam Argyle, which put CSS @scroll-timeline
on my radar (thanks, Adam!). The page features a 4-panel full-page carousel with numbers that slide into view.
The demo has been adjusted to use CSS @scroll-timeline
and mix-blend-mode: difference;.
See the Pen Scroll-Linked Animations: Counter and Snap Points (@scroll-timeline version) by Bramus (@bramus) on CodePen.
See the Pen Scroll-Linked Animations: Counter and Snap Points (JS WAAPI + ScrollTimeline version) by Bramus (@bramus) on CodePen.
The / 4
suffix is position: fixed;
on the page, and the /
character inside spins around 1turn
per panel that you scroll. As there are 4 panels in total, we spin for a total of 3turn
from top to bottom of the scroll container.
@scroll-timeline spin-slash {
source: selector(#main);
}
@keyframes rotato {
to {
transform: rotateZ(3turn);
}
}
.slash {
animation: rotato 1s linear;
animation-timeline: spin-slash;
}
~
# Full Screen Panels with Snap Points Demo, With Navigation Controls
This demo builds further upon the previous one and adds a navigation bar to it. The active indicator is powered by @scroll-timeline
: as you scroll through #main
, the active indicator moves to the correct navigation item.
There are two variants for you to check:
- There is one single active indicator shared amongst all navigation items.
- Each navigation item has its own active indicator.
I like how in this second example these indicators reflect the percentage each section is in view (or not).
See the Pen Scroll-Linked Animations: Counter and Snap Points with Navigation Controls [variant 1] (@scroll-timeline version) by Bramus (@bramus) on CodePen.
🔥 New @scroll-timeline demo I created over lunch, forked from a demo initially by @argyleink
— Bramus! (@bramus) January 18, 2021
Key techniques used:
– Scroll Snapping
– mix-blend-mode
– @scroll-timeline
– Smooth Scrolling
🔗 https://t.co/iLikwBwxgq
That's right, not single line of JavaScript in sight! pic.twitter.com/dljVUTa2kP
See the Pen Scroll-Linked Animations: Counter and Snap Points with Navigation Controls [variant 2] (@scroll-timeline version) by Bramus (@bramus) on CodePen.
And here's an alternative version that has a scroll indicator per menu item.
— Bramus! (@bramus) January 18, 2021
I like how these indicators reflect the percentage each section is in view (or not).
Took me a while to get the timings and offsets right 😅
🔗 https://t.co/HIYaAfhHxQ pic.twitter.com/gJtVTQNI9o
In the first version a line is injected underneath the navigation and its left
position is adjusted using the same @scroll-timeline
as the panels use.
In the second version each navigation item gets a line injected. The animation to show/hide the line is one shared animation for all items that does both the showing and the hiding:
@keyframes reveal-indicator {
1% { /* We use 1% instead of 0% to prevent rounding/rendering glitches */
transform: scaleX(0);
}
50% {
transform: scaleX(1);
}
99% { /* We use 99% instead of 100% to prevent rounding/rendering glitches */
transform: scaleX(0);
}
}
Now it gets tricky though: for each navigation item we create a different @scroll-timeline
whose scroll-offsets
and time-range
vary.
- The default
time-range
is4s
- The first and last items only need half an animation though (as you can’t scroll past them) so their
time-range
is set to2s
- To fix the first item’s animation we use a negative
animation-delay
of-2s
on the element itself. That way it’s animation will start “too soon”, and will already be at 50% (thus atscaleX(1)
) on page load.
~
# In Closing
That’s it for the first part of this series! We’ve covered how to create Scroll-Linked Animations between two absolute scroll-offsets, and how we can tweak our defined @scroll-timeline
s.
I hope I’ve been able to get you excited for this possible future addition to CSS throughout this post. Although it still is in its very early stages, I’m confident this will become a CSS WG Recommendation one day 🙂
I’m glad to see that the Chromium engineers are actively working on this experimental implementation, taking the time to respond to newly reported bugs. I hope that other browser vendors will follow suit soon. Relevant tracking bugs to flag/star/follow:
- Chromium: https://bugs.chromium.org/p/chromium/issues/detail?id=1023424
- Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=1676780
- Safari: https://bugs.webkit.org/show_bug.cgi?id=222295
Update 2021.03.04: Part 2 of this series got published. You can read it here.
In part 2 we cover how to create Scroll-Linked Animations based on the location of an element within the scroller, as used in this demo:
🗃 You can find all demos shown in this post over at CodePen, in a Collection Scroll-Linked Animations: Part 1. It’d be great if you could ❤️ the collection and/or the demos you like.
~
To help spread the contents of this post, feel free to retweet its announcement tweet:
🔥 The future of CSS: Scroll-Linked Animations (Part 1)
— Bram.us (@bramusblog) February 23, 2021
In this post we dig into CSS @scroll-timeline to create Scroll-Linked Animations between two absolute scroll-offsets, and how we can tweak them.
🔗 https://t.co/6oSeFYatqN
🏷 #CSS #ScrollTimeline #animation pic.twitter.com/7Eubh8DCUR
~
Thank me with a coffee.
I don\'t do this for profit but a small one-time donation would surely put a smile on my face. Thanks!
To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.
This is freaking awesome and something I’ve wanted for ages. Thanks for the great writeup.
Thanks Andreas. Glad to read you’re as excited about this as I am 🙂
Be sure to also check the 2nd part in this series, it will blow your socks off.
1 – Combined with a way to highlight text (like on medium.com), this feature could provide fine-grained bookmarking for users to share specific text on a site. I’ve always wanted this.
2 – Is a broader implication of css adding more control over animation features that websites might be more secure with less js?
1. For that we have text-fragment linking, a feature that shipped with Chrome 80 — https://www.bram.us/2020/02/10/new-in-chrome-80/
2. Performance will be the main benefit here, as these Scroll-Linked Animations run on the compositor.
Great article!
> The values for animation-duration (1s) and animation-timing-function (linear) look like they are chosen arbitrarily here, but they’re not. We’ll dig into these further down.
I read the entire article and I still don’t understand what `animation-duration` changes here.
Good catch! In an earlier version of the spec a `time-range` descriptor was required. To easily work with Scroll-Timeline it was key to set both `time-range` and `animation-duration` to the same value.
I updated the post a long time ago to no longer rely on this `time-range`, but seem to have forgotten to update that paragraph about `animation-duration`. Will do another update to fix this.
Thanks again for pointing this out.
This article is more than 1 year old… But this feature is still experimental. I tried a lot to replicate the progress bar without success before figuring out that I had to enable this feature on chrome.
When will this be available to all? It’s so powerful but also so useless if users can’t see this. I wish I never discovered that 🙁