How BoxArt Provides Fast DOM Animations

Our last article about BoxArt showed how to use BoxArt’s Animated component to animate a tile-dropping game built in React. This time, we are going to look at some features of how Animated optimizes animations for performance.

The Beastliness of Layout Thrash

There’s a performance nightmare constantly threatening when you’re animating in the browser.

As elements are animated or otherwise make changes to their box model, the browser is going to have to do some work to recalculate things and then paint the changes to the screen. These layouts and repaints are only to be expected.

But if your code simultaneously starts peppering the browser with requests for certain kinds of information—an element’s coordinates or bounding box, for example—those requests will also cause the browser to perform layouts.

Let’s look at how this becomes a real beast. Both changes to an element’s styles and questions about its position will cause the browser to execute a forced, synchronous layout. The browser stops everything while it evaluates the styles and metrics of every element. That’s right—there are no partial layouts. Every element on the page gets considered. This is necessary because changes to the box model of one element can cause cascading changes to other elements on the page (this reality is part of why web performance experts urge keeping a page’s total DOM element count as low as possible).

When you’ve got DOM changes in-flight and also code asking after what’s going on with certain elements, you can run into layout thrash. In this inefficient condition, the combination of changes and information requests cause a slew of forced layouts.

If you get this wrong, kicking off an animation involving 100 elements could cause 100 synchronous layouts when, really, one would do.

Animated works hard to avoid the specter of layout thrash.

Asking Questions at the Right Time

Here’s the ticket: ask for layout information after the DOM is done changing so that when we do ask, the layout only needs to be calculated once. That is, we don’t want to start asking those questions while the DOM is still changing.

After all, these are questions that animation code does need to ask eventually to be able to get its job done—for example, calling getBoundingClientRect on an element to figure out where it is. It’s a matter of asking at the right time.

Callbacks registered on componentDidMount and componentDidUpdate on React components will get invoked after the element is done rendering (for the first time, and after subsequent renders, respectively). But this applies to each element separately. Considering a theoretical animation that contains elements A and B, if B‘s componentDidUpdate callback is invoked, there’s no guarantee that A is done changing yet (and vice versa)—and we don’t want to start asking questions prematurely. Instead, we need to consider all of the elements involved in an animation, and postpone asking layout-triggering questions until all of them are fully updated.

Performing Magic with Promises

All right, then, how can we tell when the browser is done with computing the DOM changes across our animated elements for the first frame of the animation so that we can ask necessary questions?

If you’ve worked with JavaScript Promises, the following code should feel familiar:

Promise.resolve().then(function () {
  // The Promise always resolves, so, then,
  // the code in this handler function gets executed
});

There’s something interesting about when that handler gets invoked. The handlers for resolved Promises will get called after other stuff in the current frame has completed. Before a browser returns to the main thread to perform the actual rendering of the underlying DOM changes, handlers will get called for all newly-resolved Promises. Another way to think of this is:

Promise.resolve().then(function() {
  // Do this stuff once the changes to the DOM that
  // will be animated are ready
});

Each frame needs a “fresh” (pending) Promise, however, because once a Promise has been resolved, each further added handle is queued asynchronously. As those callbacks are added to the same queue, handles to multiple promises can end up mixed in the queue. For part of our use case, we want our layout measuring callbacks to be grouped together so that other code does not kick off a layout change inbetween our measuring callbacks.

To group our layout measuring handles together we can use a fresh Promise as when it resolves, it will queue all of them at once and so be impossible for other handles to be placed inbetween the callbacks. To make a pending promise that’ll resolve later and queue our callbacks we need to mix parts of the Promise API:

// A helper we create once.
// This promise is resolved and not pending.
const helper = Promise.resolve();
// A promise we create every frame that starts animations.
// This promise is pending and will be resolved
// asynchronously by helper.
new Promise(helper.then);

We can manage this fresh-Promise cycle by creating a function, soon, that neatly organizes callbacks that should get invoked when the DOM is ready during a given frame. Functions passed in calls to soon get attached to a single initial Promise. When that initial Promise is resolved—remember, that will happen when the given frame’s main bits of DOM computation are done—all of the callbacks will get invoked. The Promise clears itself (promise = null) once it’s resolved so that the process can be repeated in the next frame cycle (and the next and the next…).

const soon = (function() {
  const helper = Promise.resolve();
  let promise = null;
  return function(handle) {
    if (promise === null) {
      promise = new Promise(helper.then)
      .then(function() {
        promise = null;
      });
    }
    promise.then(handle);
  };
})();

Now, code that wants to interact with the DOM can use soon and will get delayed until after the DOM is done updating. soon can be used from a React component’s componentDidMount or componentDidUpdate to impose the right kind of (brief) delay while the browser performs DOM changes.

soon(function() {
  const rect = element.getBoundingClientRect();
  /* Animate! */});

There you have it! This is how BoxArt Animated solves the performance puzzle of getting information for its position animations: delaying requests of that info and kick-offs of subsequent animations using Promises.

Optimizing for the CSS transform Property

The other key thing BoxArt Animated does to improve animation efficiency is also related to reducing the number of layout updates a browser has to make.

Animated‘s default animation, as well as some of its utilities, focus on animating by using the CSS transform property. Remember that whenever a style change is made that affects an element’s box model, a layout is triggered.

Animating properties like left, top, margin or padding during each frame of the animation will cause lots of layouts. For a complex page, that can be expensive. Result: clunky, slower frames.

But there’s some good news: transform lets us move elements around without causing layout updates. Layout ignores transform—to layout, transform doesn’t really exist. transform affects how an element is rendered after the layout is considered.

For example, if you had a whole row of elements styled with display: inline-block and then used transform to shift one of them to the right, there would be a hole where that shifted element was previously. As far as the layout is concerned, the box bounding the element is still where it was before the transform, but to the eyes of the user, it overlaps the elements to the right of its original position.

As transform can be used in this way to render an element, we can move elements without requiring the layout to be recalculated for every frame. That’s good for performance.

2D and 3D Transforms

A simple transform example is a basic translation (moving an element around within the coordinate space). translate(1px, 2px) would shift an element 1px on the x axis and 2px on the y access. Another way to accomplish this is translate3d(1px, 2px, 0). Why would you choose one over the other? Once again: performance trade-offs.

Browsers handle 3D transforms differently than 2d transforms. With 3D transforms, the browser first writes the 3D-rendered content of the element into a temporary space. Then the contents of that temporary space are subsequently drawn to the screen. If the element’s contents don’t change, that same temporary space can get reused subsequently—it gets cached. Say you have a transform composed of five static images arranged in a shape. The browser can use that temporary space to draw from in one go, like one big rubber stamp containing all of the five arranged images instead of the individual redraws (five) it might take to reproduce the effect using a 2D transform (which doesn’t use a temporary space to store rendered content).

On the other hand, if the content of the element is not static and needs to be redrawn—if the five images themselves are changing—the temporary space can’t be cached (because it’s changing every time), and it may be more efficient to use a 2D transform to avoid all of the extra writes to the temporary space.

Using a translate3d transform we might build a simple animation like so:

componentDidMount() {
  soon(() => {
    // This assumes this.element is the root element of our component
    this.rect = this.element.getBoundingClientRect();
  });
}

componentDidUpdate() {
  // Use previously recorded rectangle.
  let oldRect = this.rect;
  soon(function() {
    let rect = this.element.getBoundingClientRect();
    // Store the new rect for a future animation.
    this.rect = rect;

    let start = Date.now();
    let loop = () => {
      let now = Date.now();
      // Construct a value from 0 to 1 representing where along the animation we are.
      let t = Math.min((now - start) / 300, 1);
      // Build our transform based on t.
      let left = t * (rect.left - oldRect.left);
      let top = t * (rect.top - oldRect.top);
      this.element.style.transform = `translate3d(${left}, ${top}, 0)`;
      // Until t is 1, the end of the animation, use requestAnimationFrame to call loop
      // again to update the animation.
      if (t < 1) {
        // Like `soon`, requestAnimationFrame can be something to wrap with a Promise like
        // `new Promise(requestAnimationFrame)` to group animation callbacks together to
        // avoid non-animation work getting in between multiple animations.
        requestAnimationFrame(loop);
      }
    };
    loop();
  });
}

The techniques BoxArt Animated uses to make unencumbered fast animations with DOM come down to reducing the number of times the page needs to be laid out and drawn.

BoxArt animations delay requests for layout information from the browser until as much DOM as possible is up-to-date. This way, the page only is laid out once instead of once each frame for every element in an animation. Its default animation and animation utilities use the transform CSS property in intelligent ways to prevent excessive layouts. Lastly, it uses 3D transforms to reduce redraws of moving content.

These methods take care with performance, avoiding sluggish browser overhead so that animations made with HTML, CSS and JavaScript can be complex and elegant.


Edited 10/05/2016: Improve accuracy on Promise callback handling and queuing.

Comments

Contact Us

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

Phone

+1 617-283-2807

Mail

P.O. Box 961436
Boston, MA 02196