DEV Community

Jeremy Kahn
Jeremy Kahn

Posted on • Edited on

The case for async/await-based JavaScript animations

async/await is one of my favorite features of modern JavaScript. While it's just syntactic sugar around Promises, I've found that it enables much more readable and declarative asynchronous code. Recently I've started to experiment with async/await-based animations, and I've found it to be an effective and standards-based pattern.

The problem

There is no shortage of great JavaScript animation libraries available. For most use cases, GreenSock is the gold standard and the library you should default to (and I am saying this as an author of a "competing" library). GreenSock, like most animation libraries such as Tween.js, anime.js, or mo.js, has a robust and comprehensive animation-oriented API. This API works well, but like any domain-specific solution, it's an additional layer of programming semantics on top of the language itself. It raises the barrier of entry for newer programmers, and you can't assume that one bespoke API will integrate gracefully with another. What if we could simplify our animation scripting to be more standards-based to avoid these issues?

The solution: Enter async/await

async/await enables us to write asynchronous code as though it was synchronous, thereby letting us avoid unnecessarily nested callbacks and letting code execute more linearly.

Bias warning: For the examples in this post I am going to use Shifty, an animation library I am the developer of. It is by no means the only library you could use to build Promise-based animations, but it does provide it as a first-class feature whereas it's a bit more of an opt-in feature for GreenSock and other animation libraries. Use the tool that's right for you!

Here's an animation that uses Promises directly:

import { tween } from 'shifty'

const element = document.querySelector('#tweenable')

tween({
  render: ({ x }) => {
    element.style.transform = `translateX(${x}px)`
  },
  easing: 'easeInOutQuad',
  duration: 500,
  from: { x: 0 },
  to: { x: 200 },
}).then(({ tweenable }) =>
  tweenable.tween({
    to: { x: 0 },
  })
)
Enter fullscreen mode Exit fullscreen mode

This is straightforward enough, but it could be simpler. Here's the same animation, but with async/await:

import { tween } from 'shifty'

const element = document.querySelector('#tweenable')

;(async () => {
  const { tweenable } = await tween({
    render: ({ x }) => {
      element.style.transform = `translateX(${x}px)`
    },
    easing: 'easeInOutQuad',
    duration: 500,
    from: { x: 0 },
    to: { x: 200 },
  })

  tweenable.tween({
    to: { x: 0 },
  })
})()
Enter fullscreen mode Exit fullscreen mode

For an example this basic, the difference isn't significant. However, we can see that the async/await version is free from the .then() chaining, which keeps things a bit terser but also allows for a flatter overall code structure (at least once it's inside the async IIFE wrapper).

Because the code is visually synchronous, it becomes easier to mix side effects into the "beats" of the animation:

It gets more interesting when we look at using standard JavaScript loops with our animations. It's still weird to me that you can use a for or a while loop with asynchronous code and not have it block the thread, but async/await allows us to do it! Here's a metronome that uses a standard while loop that repeats infinitely, but doesn't block the thread:

Did you notice the while (true) in there? In a non-async function, this would result in an infinite loop and crash the page. But here, it does exactly what we want!

This pattern enables straightforward animation scripting with minimal semantic overhead from third-party library code. await is a fundamentally declarative programming construct, and it helps to wrangle the complexity of necessarily asynchronous and time-based animation programming. I hope that more animation libraries provide first-class Promise support to enable more developers to easily write async/await animations!

Addendum: Handling interruptions with try/catch

After initially publishing this post, I iterated towards another powerful pattern that I wanted to share: Graceful animation interruption handling with try/catch blocks.

Imagine you have an animation running that is tied to a particular state of your app, but then that state changes and the animation either needs to respond to the change or cancel completely. With async/await-based animations, this becomes easy to do in a way that leverages the fundamentals of language.

In this example, the ball pulsates indefinitely. In the async IIFE, notice that the tweens are wrapped in a try which is wrapped in a while (true) to cause the animation to repeat. As soon as you click anywhere in the demo, the animation is rejected, thus causing the awaited animation's Promise to be treated as a caught exception which diverts the control flow into the catch block. Here the catch block awaits reposition, another async function that leverages a similar pattern to move the ball to where you clicked. Once reposition breaks and exits its while loop, the async IIFE proceeds to repeat.

This demo isn't terribly sophisticated, but it shows how async/await-based animations can enable rich interactivity with just a bit of plain vanilla JavaScript!

Top comments (4)

Collapse
 
levitabris profile image
Wei Li • Edited

This is amazing! This is absolutely the most elegant way to write web animation I know. But my brain hurts when reasoning with awaits. Anyway I could insert thousands of await keyframes using supposedly a long JSON array?

P.S. I got here from Shifty's Github page.

Collapse
 
jeremyckahn profile image
Jeremy Kahn

Hey! Sorry, I didn't see this until now. There shouldn't be any limit to how many tweens (or other Promises) you sequence via await, but you may run into performance issues if you're running thousands of animations concurrently.

I hope this helps!

Collapse
 
levitabris profile image
Wei Li

Thanks for the explanation, Jeremy!

For anyone with the same problem, I found it can be done easily via a for loop, such as:

 for (const position of arr) {
        await tweenable.tween(transformTo(position))
    }
Enter fullscreen mode Exit fullscreen mode

Vanilla is the best.

BTW, I'm looking for ways to maintain a global timeline as this approach restarts a new clock when a new Tweenable is created. Therefore, I cannot use Tweenable.seek() to revert the animation to, say the 3rd animation sequence, when the 20th animation of the sequence is currently running (seek(0) will only start over the 20th animation itself).

One way I can think of is to memorize the time taken for each step of the entire animation in an array and recreate the animation with two adjacent keyframes in vanilla js, which doesn't look nice, I suppose... Any suggestions?

Thread Thread
 
jeremyckahn profile image
Jeremy Kahn

Yeah, you touched on something I'd really like to add to Shifty at some point: sequencing! Shifty does have a Scene class, but I don't think it'll cover your whole use case here. I think what you're looking for could be achieved with a small, lightweight library on top of Shifty. I explored doing as much myself some time ago, but then got distracted by some other projects. I think Shifty is flexible enough where this sort of layer on top of it should be very achievable.

If you wind up writing something for this, please do let me know! I'd love to get it merged into Shifty is that's something you'd be interested in. 🙂