Houdini's Animation Worklet

Supercharge your webapp's animations

TL;DR: Animation Worklet allows you to write imperative animations that run at the device's native frame rate for that extra buttery jank-free smoothness™, make your animations more resilient against main thread jank and are linkable to scroll instead of time. Animation Worklet is in Chrome Canary (behind the "Experimental Web Platform features" flag) and we are planning an Origin Trial for Chrome 71. You can start using it as a progressive enhancement today.

Another Animation API?

Actually no, it is an extension of what we already have, and with good reason! Let's start at the beginning. If you want to animate any DOM element on the web today, you have 2 ½ choices: CSS Transitions for simple A to B transitions, CSS Animations for potentially cyclical, more complex time-based animations and Web Animations API (WAAPI) for almost arbitrarily complex animations. WAAPI's support matrix is looking pretty grim, but it's on the way up. Until then, there is a polyfill.

What all these methods have in common is that they are stateless and time-driven. But some of the effects developers are trying are neither time-driven nor stateless. For example the infamous parallax scroller is, as the name implies, scroll-driven. Implementing a performant parallax scroller on the web today is surprisingly hard.

And what about statelessness? Think about Chrome's address bar on Android, for example. If you scroll down, it scrolls out of view. But the second you scroll up, it comes back, even if you are half way down that page. The animation depends not only on scroll position, but also on your previous scroll direction. It is stateful.

Another issue is styling scrollbars. They are notoriously unstylable — or at least not styleable enough. What if I want a nyan cat as my scrollbar? Whatever technique you choose, building a custom scrollbar is neither performant, nor easy.

The point is all of these things are awkward and hard to impossible to implement efficiently. Most of them rely on events and/or requestAnimationFrame, which might keep you at 60fps, even when your screen is capable of running at 90fps, 120fps or higher and use a fraction of your precious main thread frame budget.

Animation Worklet extends the capabilities of the web's animations stack to make these kind of effects easier. Before we dive in, let's make sure we are up-to-date on the basics of animations.

A primer on animations and timelines

WAAPI and Animation Worklet make extensive use of timelines to allow you to orchestrate animations and effects in the way that you want. This section is a quick refresher or introduction to timelines and how they work with animations.

Each document has document.timeline. It starts at 0 when the document is created and counts the milliseconds since the document started existing. All of a document's animations work relative to this timeline.

To make things a little more concrete, let's take a look at this WAAPI snippet

const animation = new Animation(
  new KeyframeEffect(
    document.querySelector('#a'),
    [
      {
        transform: 'translateX(0)',
      },
      {
        transform: 'translateX(500px)',
      },
      {
        transform: 'translateY(500px)',
      },
    ],
    {
      delay: 3000,
      duration: 2000,
      iterations: 3,
    }
  ),
  document.timeline
);

animation.play();

When we call animation.play(), the animation uses the timeline’s currentTime as its start time. Our animation has a delay of 3000ms, meaning that the animation will start (or become "active") when the timeline reaches `startTime

  • 3000. After that time, the animation engine will animate the given element from the first keyframe (translateX(0)), through all intermediate keyframes (translateX(500px)) all the way to the last keyframe (translateY(500px)) in exactly 2000ms, as prescribed by thedurationoptions. Since we have a duration of 2000ms, we will reach the middle keyframe when the timeline'scurrentTimeisstartTime + 3000 + 1000and the last keyframe atstartTime + 3000 + 2000`. The point is, the timeline controls where we are in our animation!

Once the animation has reached the last keyframe, it will jump back to the first keyframe and start the next iteration of the animation. This process repeats a total of 3 times since we set iterations: 3. If we wanted the animation to never stop, we would write iterations: Number.POSITIVE_INFINITY. Here's the result of the code above.

WAAPI is incredibly powerful and there are many more features in this API like easing, start offsets, keyframe weightings and fill behavior that would blow the scope of this article. If you would like to know more, I recommend reading this article on CSS Animations on CSS Tricks.

Writing an Animation Worklet

Now that we have the concept of timelines down, we can start looking at Animation Worklet and how it allows you to mess with timelines! The Animation Worklet API is not only based on WAAPI, but is — in the sense of the extensible web — a lower-level primitive that explains how WAAPI functions. In terms of syntax, they are incredibly similar:

Animation Worklet WAAPI
new WorkletAnimation(
  'passthrough',
  new KeyframeEffect(
    document.querySelector('#a'),
    [
      {
        transform: 'translateX(0)'
      },
      {
        transform: 'translateX(500px)'
      }
    ],
    {
      duration: 2000,
      iterations: Number.POSITIVE_INFINITY
    }
  ),
  document.timeline
).play();
      
        new Animation(

        new KeyframeEffect(
        document.querySelector('#a'),
        [
        {
        transform: 'translateX(0)'
        },
        {
        transform: 'translateX(500px)'
        }
        ],
        {
        duration: 2000,
        iterations: Number.POSITIVE_INFINITY
        }
        ),
        document.timeline
        ).play();
        

The difference is in the first parameter, which is the name of the worklet that drives this animation.

Feature detection

Chrome is the first browser to ship this feature, so you need to make sure your code doesn't just expect AnimationWorklet to be there. So before loading the worklet, we should detect if the user's browser has support for AnimationWorklet with a simple check:

if ('animationWorklet' in CSS) {
  // AnimationWorklet is supported!
}

Loading a worklet

Worklets are a new concept introduced by the Houdini task force to make many of the new APIs easier to build and scale. We will cover the details of worklets a bit more later, but for simplicity you can think of them as cheap and lightweight threads (like workers) for now.

We need to make sure we have loaded a worklet with the name "passthrough", before declaring the animation:

// index.html
await CSS.animationWorklet.addModule('passthrough-aw.js');
// ... WorkletAnimation initialization from above ...

// passthrough-aw.js
registerAnimator(
  'passthrough',
  class {
    animate(currentTime, effect) {
      effect.localTime = currentTime;
    }
  }
);

What is happening here? We are registering a class as an animator using the AnimationWorklet's registerAnimator() call, giving it the name "passthrough". It's the same name we used in the WorkletAnimation() constructor above. Once the registration is complete, the promise returned by addModule() will resolve and we can start creating animations using that worklet.

The animate() method of our instance will be called for every frame the browser wants to render, passing the currentTime of the animation's timeline as well as the effect that is currently being processed. We only have one effect, the KeyframeEffect and we are using currentTime to set the effect's localTime, hence why this animator is called "passthrough". With this code for the worklet, the WAAPI and the AnimationWorklet above behave exactly the same, as you can see in the demo.

Time

The currentTime parameter of our animate() method is the currentTime of the timeline we passed to the WorkletAnimation() constructor. In the previous example, we just passed that time through to the effect. But since this is JavaScript code, and we can distort time 💫

function remap(minIn, maxIn, minOut, maxOut, v) {
  return ((v - minIn) / (maxIn - minIn)) * (maxOut - minOut) + minOut;
}
registerAnimator(
  'sin',
  class {
    animate(currentTime, effect) {
      effect.localTime = remap(
        -1,
        1,
        0,
        2000,
        Math.sin((currentTime * 2 * Math.PI) / 2000)
      );
    }
  }
);

We are taking the Math.sin() of the currentTime, and remapping that value to the range [0; 2000], which is the time range that our effect is defined for. Now the animation looks very different, without having changed the keyframes or the animation's options. The worklet code can be arbitrarily complex, and allows you to programmatically define which effects are played in which order and to which extent.

Options over Options

You might want to reuse a worklet and change its numbers. For this reason the WorkletAnimation constructor allows you pass an options object to the worklet:

registerAnimator(
  'factor',
  class {
    constructor(options = {}) {
      this.factor = options.factor || 1;
    }
    animate(currentTime, effect) {
      effect.localTime = currentTime * this.factor;
    }
  }
);

new WorkletAnimation(
  'factor',
  new KeyframeEffect(
    document.querySelector('#b'),
    [
      /* ... same keyframes as before ... */
    ],
    {
      duration: 2000,
      iterations: Number.POSITIVE_INFINITY,
    }
  ),
  document.timeline,
  {factor: 0.5}
).play();

In this example, both animations are driven with the same code, but with different options.

Gimme your local state!

As I hinted at before, one of the key problems animation worklet aims to solve is stateful animations. Animation worklets are allowed to hold state. However, one of the core features of worklets is that they can be migrated to a different thread or even be destroyed to save resources, which would also destroy their state. To prevent state loss, animation worklet offers a hook that is called before a worklet is destroyed that you can use to return a state object. That object will be passed to the constructor when the worklet is re-created. On initial creation, that parameter will be undefined.

registerAnimator(
  'randomspin',
  class {
    constructor(options = {}, state = {}) {
      this.direction = state.direction || (Math.random() > 0.5 ? 1 : -1);
    }
    animate(currentTime, effect) {
      // Some math to make sure that `localTime` is always > 0.
      effect.localTime = 2000 + this.direction * (currentTime % 2000);
    }
    destroy() {
      return {
        direction: this.direction,
      };
    }
  }
);

Every time you refresh this demo, you have a 50/50 chance in which direction the square will spin. If the browser were to tear down the worklet and migrate it to a different thread, there would be another Math.random() call on creation, which could cause a sudden change of direction. To make sure that doesn't happen, we return the animations randomly-chosen direction as state and use it in the constructor, if provided.

Hooking into the space-time continuum: ScrollTimeline

As the previous section has shown, AnimationWorklet allows us to programmatically define how advancing the timeline affects the effects of the animation. But so far, our timeline has always been document.timeline, which tracks time.

ScrollTimeline opens up new possibilities and allows you to drive animations with scrolling instead of time. We are going to reuse our very first "passthrough" worklet for this demo:

new WorkletAnimation(
  'passthrough',
  new KeyframeEffect(
    document.querySelector('#a'),
    [
      {
        transform: 'translateX(0)',
      },
      {
        transform: 'translateX(500px)',
      },
    ],
    {
      duration: 2000,
      fill: 'both',
    }
  ),
  new ScrollTimeline({
    scrollSource: document.querySelector('main'),
    orientation: 'vertical', // "horizontal" or "vertical".
    timeRange: 2000,
  })
).play();

Instead of passing document.timeline, we are creating a new ScrollTimeline. You might have guessed it, ScrollTimeline doesn't use time but the scrollSource's scroll position to set the currentTime in the worklet. Being scrolled all the way to the top (or left) means currentTime = 0, while being scrolled all the way to the bottom (or right) sets currentTime to timeRange. If you scroll the box in this demo, you can control the position of the red box.

If you create a ScrollTimeline with an element that doesn't scroll, the timeline's currentTime will be NaN. So especially with responsive design in mind, you should always be prepared for NaN as your currentTime. It’s often sensible to default to a value of 0.

Linking animations with scroll position is something that has long been sought, but was never really achieved at this level of fidelity (apart from hacky workarounds with CSS3D). Animation Worklet allows these effects to be implemented in a straightforward way while being highly performant. For example: a parallax scrolling effect like this demo shows that it now takes just a couple of lines to define a scroll-driven animation.

Under the hood

Worklets

Worklets are JavaScript contexts with an isolated scope and a very small API surface. The small API surface allows more aggressive optimization from the browser, especially on low-end devices. Additionally, worklets are not bound to a specific event loop, but can moved between threads as necessary. This is especially important for AnimationWorklet.

Compositor NSync

You might know that certain CSS properties are fast to animate, while others are not. Some properties just need some work on the GPU to be animated, while others force the browser to re-layout the entire document.

In Chrome (as in many other browsers) we have a process called the compositor, whose job it is — and I'm very much simplifying here — to arrange layers and textures and then utilize the GPU to update the screen as regularly as possible, ideally as fast as the screen can update (typically 60Hz). Depending on which CSS properties are being animated, the browser might just need have the compositor do it's work, while other properties need to run layout, which is an operation that only the main thread can do. Depending on which properties you are planning to animate, your animation worklet will either be bound to the main thread or run in a separate thread in sync with the compositor.

Slap on the wrist

There is usually only one compositor process which is potentially shared across multiple tabs, as the GPU is a highly-contended resource. If the compositor gets somehow blocked, the entire browser grinds to a halt and becomes unresponsive to user input. This needs to be avoided at all costs. So what happens if your worklet cannot deliver the data the compositor needs in time for the frame to be rendered?

If this happens the worklet is allowed — per spec — to "slip". It falls behind the compositor, and the compositor is allowed to re-use the last frame's data to keep the frame rate up. Visually, this will look like jank, but the big difference is that the browser is still responsive to user input.

Conclusion

There are many facets to AnimationWorklet and the benefits it brings to the web. The obvious benefits are more control over animations and new ways to drive animations to bring a new level of visual fidelity to the web. But the APIs design also allows you to make your app more resilient to jank while getting access to all the new goodness at the same time.

Animation Worklet is in Canary and we are aiming for an Origin Trial with Chrome 71. We are eagerly awaiting your great new web experiences and hearing about what we can improve. There is also a polyfill that gives you the same API, but doesn't provide the performance isolation.

Keep in mind that CSS Transitions and CSS Animations are still valid options and can be much simpler for basic animations. But if you need to go fancy, AnimationWorklet has your back!