Animation Worklet Explainer


Overview

Animation Worklet is a new primitive that provides extensibility in web animations and enables high performance procedural animations on the web. The feature is developed as part of the CSS Houdini task force.

The Animation Worklet API provides a method to create scripted animations that control a set of animation effects. These animations are executed inside an isolated execution environment, worklet which makes it possible for user agents to run such animations in their own dedicated thread to provide a degree of performance isolation from main thread. The API is compatible with Web Animations and uses existing constructs as much as possible.

Background

Scripted interactive effects (written in response to requestAnimationFrame, pointer events or async onscroll events) are rich but are subject to main thread jankiness. On the other hand, accelerated CSS transitions and animations can be fast (for a subset of accelerated properties) but are not rich enough to enable many common use cases and currently have no way to access key user input (pointer events, gestures, scroll). This is why scripted effects are still very popular for implementing common effects such as hidey-bars, parallax, pull-to-refresh, drag-and-drop, swipe to dismiss and etc. Animation Worklet provides is key building block for enabling creation of smooth rich interactive visual effects on the web while also exposing an extensibility hook in web animations.

See the Animation Worklet design principles and goals for a more extended overview of the motivations behind Animation Worklet and how the design will be evolved to support a growing set of use cases. Also see the status document for high level implementation status and timeline. Here you may find an earlier high level discussion on general approaches to address this problem.

Motivating Use Cases

Not all of these usecases are immediately enabled by the current proposed API. However Animation Worklet provides a powerfull primitive (off main-thread scripted animation) which when combined with other upcoming features (e.g., Event in Worklets, ScrollTimeline, GroupEffect) can address all these usecases and allows many of currently main-thread rAF-based animations to move off thread with significant improvement to their smoothness. See Animation Worklet design principles and goals for a more extended discussion of this.

Note: Demos work best in the latest Chrome Canary with the experimental web platform features enabled (--enable-experimental-web-platform-features flag) otherwise they fallback to using main thread rAF to emulate the behaviour.

Animation Worklet

Animation Worklet attempts to address the above usecases by introducing a new primitive that enables extensibility in the web's core animation model WebAnimations): custom frame-by-frame animate function!

How It Works

Normally, an active animation takes its timeline time and according to its running state (e.g., playing, finished) and playback rate, computes its own current time which it then uses to set its keyframe effect local time. Here is a simple example of a simple animation:

const effect = new KeyframeEffect(targetEl,
  {transform: ['translateX(0)', 'translateX(50vw)']},
  {duration: 1000}
);
const animation = new Animation(effect, document.timeline);
animation.play();

Animation Worklet allows this transformation from current time to local time to be customized via a special Javascript function animate. Similar to other Houdini worklets, these animate functions are called inside a restricted worklet context (AnimationWorkletGlobalScope) which means the don't have access to main document. Another implication is that implementor can run these off-thread to ensure smooth animations even when main thread is busy which is a key performance goal for animations.

To leverage this machinery, web developer creates a special Animation subclass, WorkletAnimation. The only difference is that the WorkletAnimation constructor takes a name argument that identifies the custom animate function to be used. Animation Worklet then creates a corresponding animater instance that represent this particlar animation and then on each animation frame calls its animate function to determine the local time which ultimately drives the keyframe effect.

Overview of the WorkletAnimation Timing Model

Here the same simple example but using Animation Worklet instead.

index.html

// Load your custom animator in the worklet
await CSS.animationWorklet.addModule('animator.js');

const effect = new KeyframeEffect(targetEl,
  {transform: ['translateX(0)', 'translateX(50vw)']},
  {duration: 1000}
);
const animation = new WorkletAnimation('my-awesome-animator', effect);
animation.play();

animator.js

registerAnimator('my-awesome-animator', class Passthrough extends StatelessAnimator {
  animate(currentTime, effect) {
    // The simplest custom animator that does exactly what regular animations do!
    effect.localTime = currentTime;
  }
});

A few notable things:

Below are a few more complex example each trying to show a different aspect of Animation Worklet.

Examples

Spring Timing

Here we use Animation Worklet to create animation with a custom spring timing.


<div id='target'></div>

<script>
await CSS.animationWorklet.addModule('spring-animator.js');
targetEl = document.getElementById('target');

const effect = new KeyframeEffect(
  targetEl,
  {transform: ['translateX(0)', 'translateX(50vw)']},
  {duration: 1000}
);
const animation = new WorkletAnimation('spring', effect, document.timeline, {k: 2, ratio: 0.7});
animation.play();
</script>

spring-animator.js:

registerAnimator('spring', class SpringAnimator extends StatelessAnimator {
  constructor(options = {k: 1, ratio: 0.5}) {
    this.timing = createSpring(options.k, options.ratio);
  }

  animate(currentTime, effect) {
    let delta = this.timing(currentTime);
    // scale this by target duration
    delta = delta * (effect.getTimings().duration / 2);
    effect.localTime = delta;
    // TODO: Provide a method for animate to mark animation as finished once
    // spring simulation is complete, e.g., this.finish()
    // See issue https://github.com/w3c/css-houdini-drafts/issues/808
  }
});

function createSpring(springConstant, ratio) {
  // Normalize mass and distance to 1 and assume a reasonable init velocit
  // but these can also become options to this animator.
  const velocity = 0.2;
  const mass = 1;
  const distance = 1;

  // Keep ratio < 1 to ensure it is under-damped.
  ratio = Math.min(ratio, 1 - 1e-5);

  const damping = ratio * 2.0 * Math.sqrt(springConstant);
  const w = Math.sqrt(4.0 * springConstant - damping * damping) / (2.0 * mass);
  const r = -(damping / 2.0);
  const c1 = distance;
  const c2 = (velocity - r * distance) / w;

  // return a value in [0..distance]
  return function springTiming(timeMs) {
    const time = timeMs / 1000; // in seconds
    const result = Math.pow(Math.E, r * time) *
                  (c1 * Math.cos(w * time) + c2 * Math.sin(w * time));
    return distance - result;
  }
}

Note that ideally once sping simulation is finished, the worklet animation would also dispatch the finish event. Adding the necessary mechanism to enable this is tracked here.

Twitter Header

Note: This assumes experimental ScrollTimeline feature.

An example of twitter profile header effect where two elements (avatar, and header) are updated in sync with scroll offset.


<div id='scrollingContainer'>
  <div id='header' style='height: 150px'></div>
  <div id='avatar'><img></div>
</div>

<script>
const headerEl = document.getElementById('header');
const avatarEl = document.getElementById('avatar');
const scrollingContainerEl = document.getElementById('scrollingContainer');


await CSS.animationWorklet.addModule('twitter-header-animator.js');
const animation = new WorkletAnimation('twitter-header',
  [new KeyframeEffect(avatarEl,  /* scales down as we scroll up */
                      [{transform: 'scale(1)'}, {transform: 'scale(0.5)'}],
                      {duration: 1000, iterations: 1}),
    new KeyframeEffect(headerEl, /* loses transparency as we scroll up */
                      [{opacity: 0}, {opacity: 0.8}],
                      {duration: 1000, iterations: 1})],
    new ScrollTimeline({
      scrollSource: scrollingContainerEl,
      timeRange: 1000,
      orientation: 'block',
      startScrollOffset: 0,
      endScrollOffset: headerEl.clientHeight}),
);
animation.play();
</script>

twitter-header-animator.js:

registerAnimator('twitter-header', class TwitterHeader extends StatelessAnimator {
  constructor(options) {
    this.timing_ = new CubicBezier('ease-out');
  }

  clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
  }

  animate(currentTime, effect) {
    const scroll = currentTime;  // [0, 1]

    // Drive the output group effect by setting its children local times.
    effect.children[0].localTime = scroll;
    // Can control the child effects individually
    effect.children[1].localTime = this.timing_(this.clamp(scroll, 0, 1));
  }
});

Swipe-to-Action

Another usecase for Animation Worklet is to enable interactive input-driven animation effects that are driven both by input events and time.

To enable this we need a way to receive pointer events in worklet (e.g. via CSS custom variables or other mechanisms) and also allow playback controls inside worklets. Both of these are natural planned additions to Animation Worklet.

Consider a simple swipe-to-action effect which follows the user swipe gesture and when finger lifts then continues to completion (either dismissed or returned to origin) with a curve that matches the swipe gesture's velocity. (See this example)

With Animation Worklet, this can be modeled as a stateful animator which consumes both time and pointer events and have the following state machines:

SwipeToCompletionAnimation

Here are the three main states:

  1. Animation is idle, where it is paused so that it is not actively ticking
  2. As soon as the user touches down, the animation moves the target to follow the user touchpoint while staying paused (optionally calculate the movement velocity, and overall delta).
  3. As soon as the user lift their finger the animation will the switch to 'playing' so that it is ticked by time until it reaches its finished state. The final state may be decided on overall delta and velocity and the animation curve adapts to the movement velocity.

Note that while in (3), if the user touches down we go back to (2) which ensures responsiveness to user touch input.

To make this more concrete, here is how this may be implemented (assuming strawman proposed APIs for playback controls and also receiving pointer events). Note that all the state machine transitions and various state data (velocity, phase) and internal to the animator. Main thread only needs to provide appropriate keyframes that can used to translate the element on the viewport as appropriate (e.g., Keyframes(target, {transform: ['translateX(-100vw)', 'translateX(100vw)']})).

registerAnimator('swipe-to-dismiss', class SwipeAnimator extends StatefulAnimator {
  constructor(options, state = {velocity: 0, phase: 'idle'}) {
    this.velocity = state.velocity;
    this.phase = state.phase;

    if (phase == 'idle') {
      // Pause until we receive pointer events.
      this.pause();
    }

    // Assumes we have an API to receive pointer events for our target.
    this.addEventListener("eventtargetadded", (event) => {
     for (type of ["pointerdown", "pointermove", "pointerup"])  {
        event.target.addEventListener(type,onPointerEvent );
     }
    });
  }

  onpointerevent(event) {
    if (event.type == "pointerdown" || event.type == "pointermove") {
      this.phase = "follow_pointer";
    } else {
      this.phase = "animate_to_completion";
      // Also decide what is the completion phase (e.g., hide or show)
    }

    this.pointer_position = event.screenX;

    // Allow the animation to play for *one* frame to react to the pointer event.
    this.play();
  }

  animate(currentTime, effect) {
    if (this.phase == "follow_pointer") {
      effect.localTime = position_curve(this.pointer_position);
      update_velocity(currentTime, this.pointer_position);
      // Pause, no need to produce frames until next pointer event.
      this.pause();
    } else if (this.phase = "animate_to_completion") {
      effect.localTime = time_curve(currentTime, velocity);

      if (effect.localTime == 0 || effect.localTime == effect.duration) {
        // The animation is complete. Pause and become idle until next user interaction.
        this.phase = "idle";
        this.pause();
      } else {
        // Continue producing frames based on time until we complete or the user interacts again.
        this.play();
      }
    }

  }

  position_curve(x) {
    // map finger position to local time so we follow user's touch.
  }

  time_curve(time, velocity) {
    // Map current time delta and given movement velocity to appropriate local time so that over
    // time we animate to a final position.
  }

  update_velocity(time, x) {
    this.velocity = (x - last_x) / (time - last_time);
    this.last_time = time;
    this.last_x = x;
  }

  state() {
    return {
      phase: this.phase,
      velocity: this.velocity
    }
  }
});

await CSS.animationWorklet.addModule('swipe-to-dismiss-animator.js');
const target = document.getElementById('target');
const s2d = new WorkletAnimation(
    'swipe-to-dismiss',
    new KeyframeEffect(target, {transform: ['translateX(-100vw)', 'translateX(100vw)']}));
s2d.play();

Why Extend Animation?

In WebAnimation, Animation is the main controller. It handles the playback commands (play/pause/cancel) and is responsible for processing the progressing time (sourced from Timeline) and driving keyframes effect which defines how a particular target css property is animated and ultimately pixels moving on the screen.

By allowing extensibility in Animation we can have the most flexibility in terms of what is possible for example animation can directly control the following:

While there is benefit in extensibility in other parts of animation stack (custom timeline, custom effect, custom timing), custom animations provides the largest value in terms of flexibility and addressing key usecases so it is the one we are tackling first.

Animation Worklet can be easily augmented in future to support other Houdini style extensibility features as well.

TODO: Also discuss other models that we have considered (e.g., CompositorWorker) that bypassed web animation altogether.

Key Concepts

Animation Worklet Global Scope

A worklet global scope that is created by Animation Worklet. Note that Animation Worklet creates multiple such scopes and uses them to execute user defined effects. In particular global scopes are regularly switched to enforce stateless and stateful animator contracts.

Animator

Animator is a Javascript class that encapsulates the custom animation logic. Similar to other Houdinig worklets, animators are registered inside the worklet global scope with a unique name which can be used to uniquely identify them.

WorkletAnimation

WorkletAnimation is a subclass of Animation that can be used to create an custom animation that runs inside a standalone animation worklet scope. A worklet animation has a corresponding animator instance in a animation worklet scope which is responsible to drive its keyframe effects. Here are the key differences compared to a regular web animation:

Note that worklet animations expose same API surface as other web animations and thus they may be created, played, paused, inspected, and generally controlled from the main document scope. Here is how various methods roughly translate:

Statefull and Statelss Animators

Sometimes animation effects require maintaining internal state (e.g., when animation needs to depend on velocity). Such animators have to explicitly declare their statefulness but by inheritting from StatefulAnimator superclass.

The animators are not guaranteed to run in the same global scope (or underlying thread) for their lifetime duration. For example user agents are free to initially run the animator on main thread but later decide to migrate it off main thread to get certain performance optimizations or to tear down scopes to save resources.

Animation Worklet helps stateful animators to maintain their state across such migration events. This is done through a state() function which is called and animator exposes its state. Here is an example:

// in document scope
new WorkletAnimation('animation-with-local-state', keyframes);
registerAnimator('animation-with-local-state', class FoorAnimator extends StatefulAnimator {
  constructor(options, state = {velocity: 0, acceleration: 0}) {
    //  state is either undefined (first time) or the state after an animator is migrated across
    // global scope.
    this.velocity = state.velocity;
    this.acceleration = state.acceleration;
  }

  animate(time, effect) {
    if (this.lastTime) {
      this.velocity = time - this.prevTime;
      this.acceleration = this.velocity - this.prevVelocity;
    }
    this.prevTime = time;
    this.prevVelocity = velocity;

    effect.localTime = curve(velocity, acceleration, currentTime);
  }

  state() {
    // Invoked before any migration attempts. The returned object must be structure clonable
    // and will be passed to constructor to help animator restore its state after migration to the
    // new scope.
    return {
      this.velocity,
      this.acceleration
    }
  }

  curve(velocity, accerlation, t) {
     return /* compute some physical movement curve */;
  }
});

Threading Model

Animation Worklet is designed to be thread-agnostic. Rendering engines may create one or more parallel worklet execution contexts separate from the main javascript execution context, e.g., on their own dedicated threads. Rendering engines may then choose to assign Animation Worklet animations to run in such contexts. Doing so allows Animation Worklet animations to avoid being impacted by main thread jank.

Rendering engines may wish to make a best-effort attempt to execute animate callbacks synchronously with visual frame production to ensure smooth animation. However it is legal for rendering engines to produce visual frames without blocking to receive animation updates from a worklet (i.e., letting the effects slip behind). For example, this could occur when the animate function callback is unable to complete before the frame deadline.

We believe that scripted animations which are run in a parallel execution environment and which limit themselves to animating properties which do not require the user agent to consult main thread will have a much better chance of meeting the strict frame budgets required for smooth playback.

Note that due to the asynchronous nature of this animation model a script running in the main javascript execution context may see a stale value when reading a target property that is being animated in a Worklet Animation, compared to the value currently being used to produce the visual frame that is visible to the user. This is similar to the effect of asynchronous scrolling when reading scroll offsets in the main javascript execution context.

Overview of the animation worklet threading model.
Overview of the animation worklet threading model.
A simplified visualization of how animators running in a parallel execution environment can sync
their update to main thread while remaining in sync with visual frame production.

Related Concepts

The following concepts are not part of Animation Worklet specification but Animation Worklet is designed to take advantage of them to enable a richer set of usecases. These are still in early stages of the standardization process so their API may change over time.

ScrollTimeline

ScrollTimeline is a concept introduced in scroll-linked animation proposal. It defines an animation timeline whose time value depends on scroll position of a scroll container. ScrollTimeline can be used an an input timeline for worklet animations and it is the intended mechanisms to give read access to scroll position.

We can later add additional properties to this timeline (e.g., scroll phase (active, inertial, overscroll), velocity, direction) that can further be used by Animation Worklet.

GroupEffect

GroupEffect is a concept introduced in Web Animation Level 2 specification. It provides a way to group multiple effects in a tree structure. GroupEffect can be used as the output for Worklet Animations making it possible for it to drive complext effects spanning multiple elements. Also with some minor proposed changes to Group Effect timing model, Animation Worklet can enable creation of new custom sequencing models (e.g., with conditions and state).

Event Dispatching to Worker and Worklets

Event Dispatching to Worker/Worklets is a proposal in WICG which allows workers and worklets to passively receive DOM events and in particular Pointer Events. This can be benefitial to Animation Worklet as it provides an ergonomic and low latency way for Animation Worklet to receive pointer events thus enabling it to implement input driven animations more effectively.

WEBIDL

WorkletAnimation extends Animation and adds a getter for its timelines. Its constructor takes:


[Constructor (DOMString animatorName,
              optional (AnimationEffectReadOnly or array<AnimationEffectReadOnly>)? effects = null,
              AnimationTimeline? timeline,
              optional WorkletAnimationOptions)]
interface WorkletAnimation : Animation {
        readonly attribute DOMString animatorName;
}

AnimationEffectReadOnly gets a writable localTime attribute which may be used to drive the effect from the worklet global scope.

partial interface AnimationEffectReadOnly {
    [Exposed=Worklet]
    // Intended for use inside Animation Worklet scope to drive the effect.
    attribute double localTime;
};

Specification

The draft specification is the most recent version.