Scroll-Linked Animations With the Web Animations API (WAAPI) and ScrollTimeline

Avatar of Bramus
Bramus on

DigitalOcean provides cloud products for every stage of your journey. Get started with $200 in free credit!

The Scroll-linked Animations specification is an upcoming and experimental addition that allows us to link animation-progress to scroll-progress: as you scroll up and down a scroll container, a linked animation also advances or rewinds accordingly.

We covered some use cases in a previous piece here on CSS-Tricks, all driven by the CSS @scroll-timeline at-rule and animation-timeline property the specification provides — yes, that’s correct: all those use cases were built using only HTML and CSS. No JavaScript.

Apart from the CSS interface we get with the Scroll-linked Animations specification, it also describes a JavaScript interface to implement scroll-linked animations. Let’s take a look at the ScrollTimeline class and how to use it with the Web Animations API.

Web Animations API: A quick recap

The Web Animations API (WAAPI) has been covered here on CSS-Tricks before. As a small recap, the API lets us construct animations and control their playback with JavaScript.

Take the following CSS animation, for example, where a bar sits at the top of the page, and:

  1. animates from red to darkred, then
  2. animates from zero width to full-width (by scaling the x-axis).

Translating the CSS animation to its WAAPI counterpart, the code becomes this:

new Animation(
  new KeyframeEffect(
    document.querySelector('.progressbar'),
    {
      backgroundColor: ['red', 'darkred'],
      transform: ['scaleX(0)', 'scaleX(1)'],
    },
    {
      duration: 2500,
      fill: 'forwards',
      easing: 'linear',
    }
  )
).play();

Or alternatively, using a shorter syntax with Element.animate():

document.querySelector('.progressbar').animate(
  {
    backgroundColor: ['red', 'darkred'],
    transform: ['scaleX(0)', 'scaleX(1)'],
  },
  {
    duration: 2500,
    fill: 'forwards',
    easing: 'linear',
   }
);

In those last two JavaScript examples, we can distinguish two things. First, a keyframes object that describes which properties to animate:

{
  backgroundColor: ['red', 'darkred'],
  transform: ['scaleX(0)', 'scaleX(1)'],
}

Second is an options Object that configures the animation duration, easing, etc.:

{
  duration: 2500,
  fill: 'forwards',
  easing: 'linear',
}

Creating and attaching a scroll timeline

To have our animation be driven by scroll — instead of the monotonic tick of a clock — we can keep our existing WAAPI code, but need to extend it by attaching a ScrollTimeline instance to it.

This ScrollTimeline class allows us to describe an AnimationTimeline whose time values are determined not by wall-clock time, but by the scrolling progress in a scroll container. It can be configured with a few options:

  • source: The scrollable element whose scrolling triggers the activation and drives the progress of the timeline. By default, this is document.scrollingElement (i.e. the scroll container that scrolls the entire document).
  • orientation: Determines the direction of scrolling, which triggers the activation and drives the progress of the timeline. By default, this is vertical (or block as a logical value).
  • scrollOffsets: These determine the effective scroll offsets, moving in the direction specified by the orientation value. They constitute equally-distanced in progress intervals in which the timeline is active.

These options get passed into the constructor. For example:

const myScrollTimeline = new ScrollTimeline({
  source: document.scrollingElement,
  orientation: 'block',
  scrollOffsets: [
    new CSSUnitValue(0, 'percent'),
    new CSSUnitValue(100, 'percent'),
  ],
});

It’s not a coincidence that these options are exactly the same as the CSS @scroll-timeline descriptors. Both approaches let you achieve the same result with the only difference being the language you use to define them.

To attach our newly-created ScrollTimeline instance to an animation, we pass it as the second argument into the Animation constructor:

new Animation(
  new KeyframeEffect(
    document.querySelector('#progress'),
    { transform: ['scaleX(0)', 'scaleX(1)'], },
    { duration: 1, fill: 'forwards' }
  ),
  myScrollTimeline
).play();

When using the Element.animate() syntax, set it as the timeline option in the options object:

document.querySelector("#progress").animate(
  {
    transform: ["scaleX(0)", "scaleX(1)"]
  },
  { 
    duration: 1, 
    fill: "forwards", 
    timeline: myScrollTimeline
  }
);

With this code in place, the animation is driven by our ScrollTimeline instance instead of the default DocumentTimeline.

The current experimental implementation in Chromium uses scrollSource instead of source. That’s the reason you see both source and scrollSource in the code examples.

A word on browser compatibility

At the time of writing, only Chromium browsers support the ScrollTimeline class, behind a feature flag. Thankfully there’s the Scroll-Timeline Polyfill by Robert Flack that we can use to fill the unsupported gaps in all other browsers. In fact, all of the demos embedded in this article include it.

The polyfill is available as a module and registers itself if no support is detected. To include it, add the following import statement to your JavaScript code:

import 'https://flackr.github.io/scroll-timeline/dist/scroll-timeline.js';

The polyfill also registers the required CSS Typed Object Model classes, should the browser not support it. (👀 Looking at you, Safari.)

Advanced scroll timelines

Apart from absolute offsets, scroll-linked animations can also work with element-based offsets:

With this type of Scroll Offsets the animation is based on the location of an element within the scroll-container.

Typically this is used to animate an element as it comes into the scrollport until it has left the scrollport; e.g. while it is intersecting.

An element-based offset consists of three parts that describe it:

  1. target: The tracked DOM element.
  2. edge: This is what the ScrollTimeline’s source watches for the target to cross.
  3. threshold: A number ranging from 0.0 to 1.0 that indicates how much of the target is visible in the scroll port at the edge. (You might know this from IntersectionObserver.)

Here’s a visualization:

If you want to know more about element-based offsets, including how they work, and examples of commonly used offsets, check out this article.

Element-based offsets are also supported by the JS ScrollTimeline interface. To define one, use a regular object:

{
  target: document.querySelector('#targetEl'),
  edge: 'end',
  threshold: 0.5,
}

Typically, you pass two of these objects into the scrollOffsets property.

const $image = document.querySelector('#myImage');

$image.animate(
  {
    opacity: [0, 1],
    clipPath: ['inset(45% 20% 45% 20%)', 'inset(0% 0% 0% 0%)'],
  },
  {
    duration: 1,
    fill: "both",
    timeline: new ScrollTimeline({
      scrollSource: document.scrollingElement,
      timeRange: 1,
      fill: "both",
      scrollOffsets: [
        { target: $image, edge: 'end', threshold: 0.5 },
        { target: $image, edge: 'end', threshold: 1 },
      ],
    }),
  }
);

This code is used in the following demo below. It’s a JavaScript-remake of the effect I covered last time: as an image scrolls into the viewport, it fades-in and becomes unmasked.

More examples

Here are a few more examples I cooked up.

Horizontal scroll section

This is based on a demo by Cameron Knight, which features a horizontal scroll section. It behaves similarly, but uses ScrollTimeline instead of GSAP’s ScrollTrigger.

For more on how this code works and to see a pure CSS version, please refer to this write-up.

CoverFlow

Remember CoverFlow from iTunes? Well, here’s a version built with ScrollTimeline:

This demo does not behave 100% as expected in Chromium due to a bug. The problem is that the start and end positions are incorrectly calculated. You can find an explanation (with videos) in this Twitter thread.

More information on this demo can be found in this article.

CSS or JavaScript?

There’s no real difference using either CSS or JavaScript for the Scroll-linked Animations, except for the language used: both use the same concepts and constructs. In the true spirit of progressive enhancement, I would grab to CSS for these kind of effects.

However, as we covered earlier, support for the CSS-based implementation is fairly poor at the time of writing:

Because of that poor support, you’ll certainly get further with JavaScript at this very moment. Just make sure your site can also be viewed and consumed when JavaScript is disabled. 😉