Animating React Elements with BoxArt

Have you ever tried writing animations into a website? It’s complicated. There’s lots of room for error, and no tool seems to fill every animation need. Recently, while writing some DOM-based games, the Bocoup team realized there wasn’t existing software for all of the projects’ animation needs. To help fill the gaps, Bocoup created BoxArt, a collection of tools for creating DOM-based games with React.

It’s not that there aren’t existing, well-made animation libraries. But they tend to be oriented to particular, ubiquitous tasks: animating elements in and out of the viewport; moving things from point A to B. That works for a great number of cases, sure, but what if there’s more complexity in your application? What if—as can be the case commonly in games built with React—an element is changing at the same time the animation is occurring? We needed a tool that could handle situations like:

  • Animation after React renders an element with inline position styles
  • Pausing and continuing an animation that has been interrupted by React re-rendering
  • Animating an element as it is reassigned to a different parent element, like when swapping items on a grid

One of BoxArt’s packages is boxart-animated, specifically created to help with the kind of challenges that React brings to complicated animations.

Animating a Tile-Matching Game

Let’s get out of the abstract and look at how boxart-animated works in a web-based tile-matching game. The object of the game is to find groups of adjacent tiles on a grid that have the same color. Clicking on these groups removes them from the board. The more tiles in a matching group, the more points are awarded for the match. As tiles are removed, the voids they leave are filled in from tiles from above, and, when an entire column of tiles is cleared, it is replaced by tiles from the next column to the right. Play continues until there are no color-matched groups remaining on the board.

Go ahead and try it out:

The Anatomy of Game Tiles

In React, the tiles in the game could be represented something like this:

<div className="game-board">
  {columns.map(column => (
    <div>
      {tiles.map(tile => (
        <div onClick={() => this.matchTile(tile)} style={{
          position: 'absolute',
          width: `${100 / grid.width}%`,
          height: `${100 / grid.height}%`,
          left: `${tile.x * 100 / grid.width}%`,
          bottom: `${tile.y * 100 / grid.height}%`,
          background: colors[tile.color],
        }}></div>
      ))}
    </div>
  ))}
</div>

The game works, but it’s a little less than charming to play without any animations. Let’s add some.

Default Translation Animation with the Animated Component

The first animation we can add is to animate the movement of tiles as they fill the spaces left by removed tiles. Let’s start with a basic animation, a simple translation of tiles from one position to another. This will give an impression that the tiles are “falling” or “sliding” into place.

BoxArt‘s boxart-animated package provides you with an Animated React component that can do a lot of work for you. Let’s start by wrapping each tile in an Animated component like so:

<div className="game-board">
  {columns.map(column => (
    <div>
      {tiles.map(tile => (
        <Animated key={tile.key} animateKey={tile.key}>
          <div onClick={() => this.matchTile(tile)} style={{ /* ... */ }}></div>
        </Animated>
      ))}
    </div>
  ))}
</div>

Using Animated in its default way without additional configuration provides a basic translation animation. Try it out!

The animateKey Property

Let’s hone in on the animateKey property here, which identifies the animation to BoxArt.

<Animated key={tile.key} animateKey={tile.key}>

React is already cut out to handle components that get shifted around in complex ways—that’s what the key property is for, to help it make sure that elements don’t get lost in the shuffle of re-renders and DOM changes. Similarly, animateKey is BoxArt’s way of keeping track of complex animations. animateKey is a little bit more restrictive with regard to uniqueness than key—while keys must be unique for elements belonging to the same parent element, animateKeys need to be unique across the application.

Think about how the tiles move around in the game. As entire columns are cleared, tiles move from the right to fill available space. To make this happen, new elements are created in the cleared column to represent the new position of the tiles moving from the right. What looks like a set of elements moving from right to left is in fact more complicated—the DOM is changing. The new elements are able to assign an animateKey value that corresponds to the tiles that they are supplanting—that way BoxArt can provide animation continuity. Animation information is, then, available in a scope outside of the elements themselves, a necessary feature when elements aren’t static in the DOM hierarchy.

Non-Linear Animations with Custom animate Callbacks

Default animations: check. From here we can start spicing things up. Instead of a plain, linear animation, let’s try out a more nuanced animation, one that makes it look like gravity is accelerating the movements of the boxes. To do this, we want to write a custom animation callback function and tell the Animated components to use it (animate={this.animation}).

<Animated
  key={tile.key} animateKey={tile.key}
  animate={this.animation}>
  <div /* ... */></div>
</Animated>

This animation function will get called when the element is re-rendered or updated by React—that is, when things need to be animated!

The animate callback receives an options object that has a whole bunch of useful members. In this first version of the callback function, we’ll meet two of these members: rect (this represents where the element will end up at the end of the animation) and lastRect (the element’s last known position).

A First, Manual animate Function

In this first variant of the animation function, we’ll do some things manually that we can later tighten up. The animation callback function here accomplishes the following:

  1. Prepare for the animation and set up some reference values
  2. Back up some of the animated element’s current styles. Overwrite the styles of the animated element with the starting-point values of the animation.
  3. Kick off the animation loop function. For each iteration (frame) of the loop, overwrite the styles of the animated element to move it to the appropriate location for that point of the animation.
  4. When the duration of the animation is complete, resolve the Promise and restore the element’s original (backed-up) styles.

Ready? Here we go!

animation(options) {
  /** 1. Prepare for the animation **/  const start = Date.now();
  // A temporary copy of BoxArt's rectangle object will give us a
  // reference container to calculate intermediate position values
  const tempRect = options.lastRect.clone();
  const gravity = window.innerWidth / 4;
  const {rect, lastRect} = options;
  // How long should the animation last?
  const duration = Math.sqrt((rect.top - lastRect.top) / gravity);

  /** 2. Back up current styles and overwrite relevant styles on element **/  // Animation will need to alter the `transform` and `zIndex` properties
  const style = {
    transform: '',
    zIndex: 1,
  };
  // Back up the current values of those properties on the element
  // so they can be restored at the end
  const replacedStyle = {
    transform: options.animatedEl.style.transform,
    zIndex: options.animatedEl.style.zIndex,
  };
  // Overwrite the element's style with the starting styles
  Object.assign(options.animatedEl.style, style);

  // Return a promise so that Animated knows when the animation is over.
  return new Promise(resolve => {
    const loop = () => { // For each frame of the animation...
      // Figure out our proportional place in the animation cycle
      const seconds = (Date.now() - start) / 1000;
      const t = Math.min(seconds / duration, 1);
      // Place the element at its new location between rect and lastRect.
      // Calculate the new top and left coordinates based on the previous
      // coordinates (in lastRect)
      tempRect.top = (rect.top - lastRect.top) * t * t + lastRect.top;
      tempRect.left = (rect.left - lastRect.left) * t * t + lastRect.left;
      // The element needs to move from `rect` (its current position) to
      // `tempRect`. Construct a transform for this:
      style.transform = `transform3d(${tempRect.left - rect.left}px, ${tempRect.top - rect.top}px, 0)`;
      // Make it go! Write the styles to the element.
      Object.assign(options.animatedEl.style, style);
      // Are we done here? If the loop is complete, resolve the Promise.
      if (t * t >= 1) {
        return resolve();
      }
      // If not, register with the browser to run another frame soon.
      else {
        requestAnimationFrame(loop);
      }
    };
    /** 3. Kick of the animation loop itself **/    loop();
  })
  /** 4. We're done! Restore the original styles */  .then(() => Object.assign(options.animatedEl.style, replacedStyle));
}

This animation callback creates the animations you can see in this version of the demo:

All right, that’s the long-winded form of an animation function. However, while building BoxArt, we noticed some recurring needs that could be taken care of more easily.

boxart-animated Tricks and Treats

Several additional helpful features are available via properties on the options object passed to animate. We can tighten up the animate function by taking advantage of these.

An Easier Way to Manage Styles

Remember how we manually stored a backup of styles for an element and re-applied them after the animation completed? There’s an easier way using the replaceStyle and restoreStyle functions on options:

animate (options)
  /* ... */  const style = {
    transform: '',
    zIndex: 1
  };
  options.replaceStyle(style);
  /* ... */  return new Promise(resolve => {
    const loop = () => {
      /* ... */    };
  })
  .then() => options.restoreStyle());
}

An Easier Loop

Also, the process for handling loop iterations and deciding when we’re doing was a little cumbersome. How about this instead, using options.timer?

animation (options) {
  /* ... */  options.replaceStyle(style);
  const timer = options.timer();
  timer.loop(() => {
    /* ... */  })
  .then(() => options.restoreStyle());
  // Return timer instead of the Promise chain started by loop. Timer is
  // "thenable" and resolves when all loops resolve
  return timer;
}

Interpolation and More

What happens inside the animation loop can be simplified, too. Instead of performing the detailed math and constructing CSS transform rules, the following revision takes advantage of the interpolate and transformStyle methods available on lastRect and rect, respectively, and it uses the setStyle function to assign the resulting styles to the animated element:

animation(options) {
  /* ... */  timer.loop(() => {
    const seconds = (Date.now() - start) / 1000;
    const t = Math.min(seconds / duration, 1);
    // Place the element at its location this frame between rect and lastRect with interpolate.
    lastRect.interpolate(rect, t * t, tempRect);
    // Build and assign a transform from rect to where tempRect is.
    rect.transformStyle(tempRect, style);
    // options can set the new style as well
    options.setStyle(style);
    // Like the if in the example before `loop` loops until its handle returns a value equal or greater than 1.
    return t * t;
  })
  .then(() => options.restoreStyle());
  return timer;
}

Canceling Animations

To be thorough, our animation should take care of what happens if it’s canceled. The function passed to timer.cancelable will get invoked if the animation is interrupted. The returned object from this function—tempRect—will get used as lastRect for the next animation call. That way an interrupted animation can resume smoothly.

animation(options) {
  // ...
  options.replaceStyle(style);
  const timer = options.timer();
  // Define a cancel callback
  timer.cancelable(() => {
    // Restore the styles to the element
    options.restoreStyle();
    // Since tempRect is kept up to date while this animation runs we can
    // return that as the in-progress data to the new animation.
    return tempRect;
  });
  // Define the loop callback
  timer.loop(() => { });
  return timer;
}

All Together Now: The Briefer animate function

animate (options) {
  const start = Date.now();
  const tempRect = options.lastRect.clone();
  const gravity = window.innerWidth / 4;
  const {rect, lastRect} = options;
  const duration = Math.sqrt((rect.top - lastRect.top) / gravity);
  const style = {
    transform: '',
    zIndex: 1
  };
  options.replaceStyle(style);
  timer.cancelable(() => {
    options.restoreStyle();
    return tempRect;
  });
  timer.loop(() => {
    const seconds = (Date.now() - start) / 1000;
    const t = Math.min(seconds / duration, 1);
    lastRect.interpolate(rect, t * t, tempRect);
    rect.transformStyle(tempRect, style);
    options.setStyle(style);
    return t * t;
  })
  .then(() => options.restoreStyle());
  return timer;
}

More Examples of BoxArt Animations

Other animations in the game can be created with BoxArt, too. Matched tiles can “explode” as they’re removed, and tiles can be animated as they appear and disappear.

All three of these animations are used by tiles. To figure out which—explode, appear or disappear—to apply in a given situation, a tileAnimations function can be registered as the animate callback:

<Animated
  key={tile.key} animateKey={tile.key}
  animate={options => this.tileAnimations(options, tile)}>
  <div /* ... */></div>
</Animated>

tileAnimations(options, tile) {
  // pick and return the appropriate animation function for this tile's state
  // explodeAnimation, appearAnimation or disappearAnimation
}

Exploding Tiles

explodeAnimation(options, tile) {
  const {rect, lastRect} = options;
  const tRect = lastRect.clone();
  const gravity = options.agent.rect.width / 4;
  const start = Date.now();
  // Build a velocity based off the center of the match so that pieces
  // explode away from there.
  const vx = (tile.x - tile.matchX) * gravity / 4 + (Math.random() - 0.5) * gravity / 2;
  const vy = -(tile.y - tile.matchY) * gravity / 4 - Math.random() * gravity;
  const angle = Math.PI * Math.random() * 4;
  const style = {
    transform: '',
    zIndex: 2,
  };
  const timer = options.timer();
  timer.cancelable(() => {
    this.cleanTile(tile);
    return tRect;
  });
  timer.loop(() => {
    const seconds = Math.min((Date.now() - start) / 1000, 1);
    const subT = 1 - seconds;
    tRect.left = lastRect.left + vx * seconds;
    tRect.top = lastRect.top + vy * seconds + gravity * seconds * seconds;
    tRect.width = lastRect.width * subT;
    tRect.height = lastRect.height * subT;
    tRect.angle = angle * seconds;
    rect.transformStyle(tRect, style);
    options.setStyle(style);
    return seconds;
  })
  // Instead of restoring the style, let the render state remove the tile.
  .then(() => this.cleanTile(tile));
  return timer;
}

The result:

Entering and Exiting Tiles

appearAnimation(options) {
  const start = Date.now();
  const style = {transform: ''};
  options.replaceStyle(style);
  const timer = options.timer();
  timer.cancelable(() => {
    options.restoreStyle();
    return options.lastRect;
  });
  timer.loop(() => {
    const t = Math.min((Date.now() - start) / 1000, 1);
    const angle = Math.PI / 2 * (1 - t);
    style.transform = `translateZ(0) scale(${t}) rotateZ(${angle}rad)`;
    options.setStyle(style);
    return t;
  });
  .then(() => options.restoreStyle());
  return timer;
}

disappearAnimation(options, tile) {
  const start = Date.now();
  const style = {transform: ''};
  options.replaceStyle(style);
  const timer = options.timer();
  timer.cancelable(() => {this.cleanupTile(tile);});
  timer.loop(() => {
    const t = 1 - Math.min((Date.now() - start) / 1000, 1);
    const angle = Math.PI / 2 * (1 - t);
    style.transform = `translateZ(0) scale(${t}) rotateZ(${angle}rad)`;
    options.setStyle(style);
    return 1 - t;
  });
  .then(() => this.cleanupTile(tile));
  return timer;
}

And, voila!

Looking at all this together, we can see how BoxArt has utilities for animating events in response to their rendering. It can track relevant animating state across two different elements representing the same gameplay item in different locations of the DOM. It takes care of some of the grunt work related to box positioning and shape, using CSS transforms to replace, set and restore styles.

Comments

Contact Us

We'd love to hear from you. Get in touch!

Boston

201 South Street, Boston, MA 02111

New York

315 Church St, New York, NY 10013

Phone & Email

(617)379-2752 hello@bocoup.com