Scrubbing via the Web Animations API

A question appeared on a great Web Animations API article on CSS Tricks about whether or not the API would ever support scrubbing through an animation. As is often the case it does support that, but at a lower level (meaning you can’t just say animation.showControlsPlease()).

See the Pen Scrub Multiple Animations in WAAPI by Dan Wilson (@danwilson) on CodePen.

The Power of currentTime

Creating a player control like the scrubber is possible thanks to the API’s currentTime property which is a read/write value that shows (or sets) what millisecond the animation is at. An animation with only one iteration will have a currentTime with a max value of the duration plus any delays.

Creating a scrubber for a single animation then becomes an exercise in currentTime management. Things get more interesting as we want to scrub through multiple animations as a group, which is where we will focus (though what is covered here can be simplified to handle scrubbing one animation).

Our example focuses on five separate elements with one animation each (a transform that moves each element to the right with a rotation) that all have the same duration. Each additionally has a delay to stagger the animations. Since currentTime includes delays, as the delay gets bigger, so does the max currentTime value.

We add one useful property to the animations to counter the delay: an endDelay. This does not currently have a CSS animation counterpart, but effectively does what delay does… just at the end. This is particularly important when thinking about currentTime. If I can set up all my animations to start playing at the same time and stop playing at the same (again, both delays will be used to caluclate the max currentTime value), then I can use a single control/scrubber to affect all the animations at once.

var divs = Array.from(document.querySelectorAll('div'));
var animations = [];
const DURATION = 8000;

divs.forEach((div, i) => {
  var anim = div.animate({
    transform: ['translateX(0) rotate(0deg)', 'translateX(80vw) rotate(2700deg)']
  }, {
    duration: DURATION,
    easing: 'ease-in-out',
    fill: 'both',
    delay: DURATION / 4 * i,
    endDelay: DURATION - (DURATION / 4 * i)
  });
  
  animations.push(anim);
});

Now all the animations will kick off at effectively the same time (since I am calling animate() all together). The currentTime will start counting up for each, even though they don’t all start moving at once since currentTime includes delay and endDelay. Each has a duration of 8000ms, and each also has a delay/endDelay combination that adds up to 8000ms. Therefore, the five animations all will have a currrentTime that maxes out at 16000ms.

The Controller

I’m using a simple unstyled HTML range input in this example to act as the scrubber with a min value of 0 and a max value of 16000 to match our min/max currentTime.

We need three things now:

  • An animation of the range slider thumb when the animation is playing to keep in sync with the currentTime
  • An event listener for when a user is dragging the slider to pause and set the currentTime
  • An event listener to play the animation again when the user is done dragging

When the animation is playing we can use requestAnimationFrame to move the range slider according to the currentTime of the animations.

var isPlaying = true;
var scrub = document.getElementById('scrub');

adjustScrubber();
function adjustScrubber() {
  if (isPlaying) {
    scrub.value = animations[0].currentTime;
    requestAnimationFrame(adjustScrubber);
  }
}

When the user is scrubbing through the full animation we can pause our individual animations and set their currentTimes based on the range value:

scrub.addEventListener('input', e => {
  var time = e.currentTarget.value; //The range's value will be: 0 <= val <= 16000
  animations.forEach(animation => {
    animation.currentTime = time;
  });
  pauseAll();
});

function pauseAll() {
  isPlaying = false;
  animations.forEach(animation => {
    animation.pause();
  });
}

Finally we can play everything again when we stop dragging and release the mouse/touch:

scrub.addEventListener('change', e => {
  if (animations[0].currentTime >= e.currentTarget.getAttribute('max')) {
    finishAll();
    return false;
  }
  requestAnimationFrame(adjustScrubber);
  playAll();
});
function playAll() {
  isPlaying = true;
  animations.forEach(animation => {
    animation.play();
  });
}
function finishAll() {
  isPlaying = false;
  animations.forEach(animation => {
    animation.finish();
  });
}

This has an additional check for when we stop dragging at the max value (16000). In that case we actually don’t want it to play again, but we do want it to “finish” the animations and get them to their full end states. To keep requestAnimationFrame from firing forever once finished, we add an additional onfinish callback to one of the animations that toggle the isPlaying flag to false.

animations[0].onfinish = function() {
  isPlaying = false;
  console.log('finished');
}

The Wrap Up

One limitation of the example to note: The way I’ve structured the event listeners (a combination of input and change events) means it will work with touch/mouse… but not the arrow keys on a keyboard. I will be investigating better approaches for that to make this more accessible.

This scrubbing process requires a certain setup where all the animations start at the same time and have the same length. Going beyond this is possible with the current tools, but it requires a bit more thought and customization. Similar processes could be used to adjust the timeline based on scroll or other inputs.

We will have more options as the Web Animations API expands. The second level of the spec is exploring grouping and sequencing of animations such that there are multiple animations going along a shared timeline. I love that we can already accomplish a lot though with the default individual timelines and currentTime property.