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 currentTime
s 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.