The Future of CSS: Scroll-Linked Animations with @scroll-timeline (Part 1)

🚨 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

  1. Primer: Scroll-Linked Animations vs. Scroll-Triggered Animations
  2. Your first Scroll-Linked Animation (Progress Bar Demo)
  3. Tweaking the Offsets (Parallax Cover Demo)
  4. Changing the Scroll Orientation
  5. Changing the Scroll Container (In-Page Gallery Demo)
  6. In-Between Summary
  7. More Demos
    1. Parallax Cover to Sticky Header Demo
    2. Full Screen Panels with Snap Points Demo
    3. Full Screen Panels with Snap Points Demo, With Navigation Controls
  8. In Closing

~

# 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.

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:

  1. An Animation
  2. A Scroll Timeline
  3. A way to link both

~

# 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 the width to 100vw and animate transform: scaleX(…); instead. To make that work properly we have to set the transform-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 to forwards.
  • 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.

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:

  1. source
  2. orientation
  3. 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 equals 0s Animation Progress.
  • 100% Scroll Progress equals 1s 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.

🔥 TIP: Always set 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.

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 your animation-duration will have advanced to 0s
  • At 80vh your animation-duration will have advanced to 0.5s, as that 80vh is defined “halfway the array of values”
  • At 100vh your animation-duration will have advanced to 1s
🔥 TIP: Always set two values for 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.

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-timelines 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 using selector(<id-selector>)
  • The orientation is vertical or horizontal. Using logical units inline and block is also possible. The initial value is auto.
  • 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:

  1. Parallax Cover to Sticky Header Demo
  2. Full Screen Panels with Snap Points Demo
  3. 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.

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;.

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:

  1. There is one single active indicator shared amongst all navigation items.
  2. 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).

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 is 4s
  • The first and last items only need half an animation though (as you can’t scroll past them) so their time-range is set to 2s
  • 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 at scaleX(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-timelines.

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:

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:

~

Did this help you out? Like what you see?
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!

BuymeaCoffee (€3)

To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.

Published by Bramus!

Bramus is a frontend web developer from Belgium, working as a Chrome Developer Relations Engineer at Google. From the moment he discovered view-source at the age of 14 (way back in 1997), he fell in love with the web and has been tinkering with it ever since (more …)

Unless noted otherwise, the contents of this post are licensed under the Creative Commons Attribution 4.0 License and code samples are licensed under the MIT License

Join the Conversation

14 Comments

  1. 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?

  2. 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.

    1. 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.

  3. 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 🙁

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.