Performance

There are two primary factors that contribute to the performance of a web animation: rendering, and hardware acceleration.

Rendering is the process of taking an update to the DOM and reflecting that change on screen. This affects the performance of all animations.

Hardware acceleration is the ability to run an animation off the main JavaScript thread. This affects the performance of animations that run at the same time as other JS processes, like React rendering or other data crunching.

Let's take a deeper look at each.

Rendering

When we update the styles an element:

element.style.height = "500px"

The browser needs to reflect this change by re-rendering the page, taking the HTML and CSS and turning it into an image that shows on your screen.

Generally speaking, the steps a browser's renderer takes to do this are:

  1. Layout: Calculate the size and position of elements on the page
  2. Paint: Draw the page into graphical layers - essentially individual images that make up the page
  3. Composite: Draw these layers to the viewport.

As a rule of thumb, it is quicker to do less work.

Slow rendering

For instance, if we update one element's height, this changes the size of the element. Changing the size of an element might then affect the size and/or position of a sibling or child element, which itself might have knock-on effects, and so on. So we need to recalculate the layout of all the affected elements.

Whenever the layout of a page changes, the browser also needs to repaint and recomposite the affected layers too. Many styles affect layout, like height, border-width, padding, position, etc. Luckily these styles are usually easy to spot when changing them affects the size and/or position of the element (with the exception of transform).

Smooth animations usually run at 60 frames a second (fps), the same as most screen refresh rates. So if we want to change the height of an element at 60fps, we need to be able to re-render in 16.7 milliseconds. Re-renders can easily take upwards of 100ms! This is why animating layout is so often discouraged.

However, this isn't a hard and fast rule. If an element's layout is isolated, for instance it has position: absolute and very few children, then you might be able to animate it smoothly. Just make sure you test these animations on low-powered devices.

Fast rendering

The best performing styles are those that trigger only the third rendering step, the compositor.

In every modern browser, transform and opacity will operate directly on a layer. These are therefore the safest values to animate across all devices.

Browsers are adding more values to this list, too. For instance, Chrome and Firefox handle filter and SVGs entirely on the compositor, and Chrome is adding support for background-color and clip-path soon.

Additionally, because Motion One is built on the Web Animations API, browsers are smart enough to automatically place elements animating these values on a new graphical layer.

Everything in-between

There are also plenty of values like box-shadow and border-radius that can be updated without triggering a render, but do require a potentially expensive paint (step 2).

You should always test the animation of these properties on low-powered devices.

However, there are still ways you can improve the performance of these animations.

Reduce layer size

First, the time a browser takes to repaint a layer is proportional to its size. So you can improve the performance of these animations by making smaller layers.

You can hint to the browser that they should create a new layer by setting the willChange style to "transform":

element.style.willChange = "transform"
animate(element, { borderRadius: "50%" })

Creating new layers doesn't come for free. Each takes space on the GPU. So do so sparingly.

Use alternative styles

Second, you can try replacing styles will better-performing alternatives.

We've already seen that browsers are improving support for filter on the compositor. So instead of animating boxShadow, animate filter with the drop-shadow function:

// ❌
animate(element, { boxShadow: "10px 10px black" })

// ✅
animate(element, { filter: "drop-shadow(10px 10px black)" })

Likewise, browsers are adding clipPath to the compositor. So instead of animating borderRadius, animate clipPath with the inset function:

// ❌
animate(element, { borderRadius: "50px" })

// ✅
animate(element, { clipPath: "inset(0 round 50px)" })

Which is which?

So how do you tell which styles will trigger layout or paint, and which will just trigger composition?

Most tutorials on this subject will recommend CSS Triggers. While this is still a good guide to catching layout-triggering styles (border, width, top), it's very out of date, missing data on clip-path, filter and outdated info on others.

Browsers are changing all the time so the best approach is when venturing outside transform and opacity to test cross-browser and cross-device. Every browser has performance profiling tools, most can remotely debug mobile devices, so be sure to use those to investigate further.

Hardware acceleration

Rendering performance should be your primary focus because it will be a consideration in every animation you make. But there's the additional factor of the animation process itself and whether it can be hardware accelerated.

An animation, at its most basic, mixes two values over a duration of time. For example, if we wanted to animate between "100px" and "200px" over one second, if 0.5 seconds has elapsed our animation would calculate "150px". This is a very simple calculation and doesn't produce noticeable overhead compared to rendering.

However, in a browser there are several ways we can compute this value:

  • With JavaScript, using requestAnimationFrame (like Greensock and Anime.js)
  • With the Web Animations API
  • With CSS

The JavaScript code will always run on the main JS thread. This means if your app is running other JS code at the same time, your animation code could be blocked from running at all. This will result in janky animations.

However, WAAPI and CSS can run some animations off the main JS thread, directly on the compositor itself. As this is usually a GPU, this is often referred to as hardware acceleration.

An animation that is hardware accelerated will remain smooth, no matter how busy your main JS thread becomes.

Because Motion One is built on WAAPI, it can also run hardware accelerated animations.

Accelerated values

As a general rule, if a style invokes only the composite rendering step, it can theoretically be animated on it, too.

transform and opacity are widely supported compositor styles and these do usually animate on the compositor.

Other values like filter, background-color, clip-path and SVGs either have support or are gaining it in most browsers.

CSS variables and individual transforms

Motion One is unique from WAAPI and CSS in that it supports the animation of individual transforms:

animate(".box", { x: 100, scale: 2 })

Under the hood, it is animating CSS variables, and currently these are not accelerated, even though they're being applied to transform.

So if hardware acceleration is crucial in your use-case, stick to animating transform:

animate(".box", { transform: "translateX(100px) scale(2)" })

Other exceptions

Even when animating supposedly performant styles, each browser has different rules for when an animation does or doesn't receive acceleration.

For instance, until very recently, if Chrome detected a % based transform like this:

animate(element, { transform: "translateX(100%)" })

It wouldn't be hardware accelerated.

Webkit's exceptions

Webkit has quite a number of bail-outs from accelerated animations.

This seems to be because it attempts to leverage Core Animation, and as a result many of the bugs in that then surface in Webkit.

Additionally, Core Animation doesn't have a full feature overlap with the WAAPI spec.

The short term fix usually employed by the Webkit team is to detect the conditions that lead to the bugs or where the specs are mismatched and then disable hardware acceleration. Leading to a patchwork of conditions you should be aware of.

They are:

If playbackRate is set to a value other than 1:

animation.playbackRate = 2

If direction is set to a value other than normal:

animate(element, { opacity: 0 }, { direction: "reverse" })

If step easing is used:

animate(element, { opacity: 0 }, { easing: "steps(2, start)" })

If cubic-bezier easing is used with a y value outside of the 0-1 range (commonly used for overshoot), AND there are more than two keyframes or two keyframes that don't use linear easing:

animate(
  element,
  { translate: ["none", "translateX(100px)", "scale(2)"] },
  { easing: "cubic-bezier(0.34, 0, 0.32, 1.22)" }
)

Finally, filter is supported, but only in macOS.

There are also a myriad of timing bugs that result in massive delays in animations or synchronisation issues between the main thread and compositor.

For this reason, Motion One actually disables hardware acceleration by default in Webkit.

We'll be monitoring the state of accelerated animations in Webkit and hopefully can remove this limitation in the future. Until then, it is possible to allow Webkit to run accelerated animations with the allowWebkitAcceleration option:

animate(element, { translate: "scale(2)" }, { allowWebkitAcceleration: true })

However, we recommend thoroughly testing these animations in Safari and an embedded WKWebView browser like the one used in iOS Chrome.

Progressive enhancement

All of this is to say, it can be a minefield ensuring your animations are hardware accelerated.

We recommend treating acceleration like progressive enhancement. It's great when a browser supports it, but usually not essential.

If you do encounter a lot of jank from a busy JavaScript thread, stick to transform and opacity, set allowWebkitAcceleration to true, and ensure you test thoroughly in Webkit browsers.

Conclusion

To achieve smooth animations your main priority should be which values you animate. As a developer, that is the thing you have most control over.

transform and opacity are cheapest to render in all browsers. Browsers are improving the rendering performance of other styles (and SVG) all the time, with filter, background-color and clip-path on the horizon.

Layout-triggering styles can be animated on elements that don't affect the layout of surrounding elements (for instance if they're position: absolute). But make sure you test these animations in many browsers, especially low-powered devices.

Finally, hardware acceleration is an excellent tool to avoid jank if your website is performing heavy processing during an animation. But it isn't something you have full control over, there are many conditions under which it gets disabled.