Approaching an animation using actors

April 8, 2024

I recently stumbled across a talk which gave an introduction to state machines and the actor model. The way those topics were presented made me think of an animation problem I once faced when developing a web application.

The problem is a simple card flip. The card has a front and a back side and when the user hovers into the card, the front should flip and reveal the back side. When the mouse leaves the card, the card should flip back. When the back side is showing we also want to allow the user to pin the back, so when they leave the card with the mouse, it won't automatically flip back.

To help with the user experience we do not want to immediately kick off the flip effect when the user hovers into the card. If they aim for a different element and just pass over the card, we don't want to start the animation. It should only show once a grace period of some hundred milliseconds has passed.

Since actors are all about managing some internal state let's think about how we might model this behavior:

type States =
  | "idle" // card is idle
  | "might-flip" // card was hovered but grace period has not yet passed
  | "flipping" // card is flipping to the back
  | "flipped" // card shows back side
  | "locked" // back side is locked
  | "unflipping"; // card is turning from back to front

Let's think about the actions that might trigger those state transitions:

type Action =
  | "hover" // mouse enters card
  | "unhover" // mouse leaves card
  | "flip" // start the flip effect
  | "flip-end" // end the flip effect
  | "toggle-lock"; // lock the card to show the back

We have developed a little domain language for describing interactions and states for our card animation. We can use this language to express our intended behaviour and the rules it should obey.

As far as I understood it, actors are units of behavior with internal state that communicate via messages. Let's see how this might be relevant for us. On the one hand we have our card state. Sending messages (dispatching actions) to this state will cause state transitions according to rules which we are about to specify. If the card is in the idle state, for example, and the hover action is dispatched, the card should transition to the might-flip state. On the other hand we need some kind of entity that can actually perform the animating and signal back to our card state when the animation has finished, so it can transition to a resting state (either idle or flipped). Put differently, the animation "actor" will send the flip-end action to the card "actor". Furthermore: we want to send the flip action to our card state once we know that the user stayed on the card longer than our defined grace period.

Using the Web Animations API

Let's think about what we actually need for performing the animation. We need two HTML elements that each represent a side of the card. The card that represents the back side initially shoud be rotated by 180 degrees. When we want to reveal the back side we rotate the front element 180 degrees so it faces away from the user. Simultaneously we rotate the back element to 360 degrees - and vice versa when we do the reverse.

const keyframesOne = [
  {transform: "rotateX(0deg)"},
  {transform: "rotateX(180deg)"},
];

const keyframesTwo = [
  {transform: "rotate(180deg)"},
  {transform: "rotate(360deg)"},
];

To be able to perform the animation we therefore need some context with which our state machine can interact. This context will mainly be the element references of the DOM elements and the instances of running animations.

Card State

Let's model our card state:

function transition(state: States, action: Action): States {
  switch (state.type) {
    case "idle":
      if (action === "hover") {
        // Start timer for grace period and
        // trigger flip once it has elapsed
        return "might-flip";
      }
      break;

    case "might-flip":
      if (action === "flip") {
        // Kick off animation
        return "flipping";
      }
      if (action === "unhover") {
        return "idle";
      }
      break;

    case "flipping":
      if (action === "unhover") {
        // Reverse animation from flipping to unflipping
        return "unflipping";
      }

      if (action === "flip-end") {
        return "flipped";
      }
      break;

    case "unflipping":
      if (action === "flip-end") {
        return "idle";
      }
      if (action === "hover") {
        // Reverse animation from unflipping to flipping
        return "flipping";
      }
      break;

    case "flipped":
      if (action === "toggle-lock") {
        return "locked";
      }

      if (action === "unhover") {
        // Kick off animation
        return "unflipping";
      }
      break;

    case "locked":
      if (action === "toggle-lock") {
        return "flipped";
      }
      break;
  }

  return state;
}

So far we have written a stateless calculation on how our card state should behave. I added comments to the transitions where we want to spawn concurrent behavior that sends back actions into our card state:

  • starting a timer that calls us back with the action to kick off the animation
  • starting the actual animations that call us back when they have finished so our card can transition into a resting state

Let's think about how we want to implement this. This dispatch function allows us to send actions into the card state. animations and frontRef and backRef are our global context that we need to keep around to execute the animations in the DOM.

First, the timer for the grace period:

function mightFlip() {
  const flip = () => dispatch("flip");
  const timeout = setTimeout(flip, gracePeriod);
  return () => clearTimeout(timeout);
}

Here's how to handle the animations:

function animationEndPromise(animation: Animation) {
  return new Promise<void>((resolve) => {
    animation.onfinish = function () {
      resolve();
    };
  });
}
function animate(frontFrame: Keyframe[], backFrame: Keyframe[]) {
  animations = [
    frontRef.animate(frontFrame, timing),
    backRef.animate(backFrame, timing),
  ];

  Promise.all(animations.map(animationEndPromise)).then(() =>
    dispatch("flip-end")
  );
}

// Animate front -> back
function animateFlip() {
  animate(keyframesOne, keyframesTwo);
}

// Animate back -> front
function animateUnflip() {
  animate(keyframesTwo, keyframesOne);
}

// If the user unhovers the card while the animation is happening
// just reverse it
function reverseAnimation() {
  animations?.forEach((anim) => anim.reverse());
}

Now we need a way to call those functions when the respective state transitions happen. Taking the advice from the talk mentioned above, we should model our state transitions the following way: (state, action) => (state, effects). Instead of only returning just a new state, we also return one or more effects that should be executed.

type State<S extends States, E extends Effect> = {
  type: S;
  effect?: E;
};

function s<Name extends States, E extends Effect>(
  name: Name,
  e?: E
): State<Name, E> {
  return {type: name, effect: e};
}

We can now modify our state transition function to include those concurrent behaviours:

function transition(
  state: State<States, Effect>,
  action: Action
): State<States, Effect> {
  switch (state.type) {
    case "idle":
      if (action === "hover") {
        return s("might-flip", mightFlip);
      }
      break;
  }
  // ...
  return state;
}

We need a function that calls this transition function and executes the effects. Since those effects execute concurrently we need a way to clean them up if we decide to cancel them. This will be our dispatch function:

let cleanupFns = [];

function dispatch(action: Action) {
  cleanup();

  state = transition(state, action);
  effects(state);
}

// Execute effects and keep track of their clean up functions
function effects(state: State<States, Effect>) {
  if (state.effect) {
    const cleanup = state.effect();
    if (cleanup) {
      cleanupFns.push(cleanup);
    }
  }
}

function cleanup() {
  cleanupFns.forEach((fn) => fn());
  cleanupFns = [];
}

Finally, let's define our handlers that we can provide to our DOM elements:

function onHover() {
  dispatch("hover");
}

function onUnhover() {
  dispatch("unhover");
}

function onPin() {
  dispatch("toggle-lock");
}