Performance #4 - CSS & JS Animations — Compositor-Only Properties & rAF

Animate only transform and opacity to stay on the compositor thread. Here's why those two properties are free, and how requestAnimationFrame keeps JS animations in sync with the browser.

8 min read
Performance
Animation
CSS

TABLE OF CONTENTS
Performance #4 - CSS & JS Animations — Compositor-Only Properties & rAF

Animations are where performance becomes viscerally obvious. A janky scroll or a stuttering modal open is immediately noticed. Whether you write your animations in CSS or JavaScript determines which rendering pipeline steps they trigger — and whether they can run at 60fps without touching the main thread at all.


The Rendering Cost of Animations

Every animation that changes layout geometry — width, height, top, left, margin, padding — forces the browser through the full pipeline: layout → paint → composite. At 60fps, you have 16.67ms per frame. Layout alone can consume most of that budget.

The goal is to animate only properties that skip layout and paint entirely.


Compositor-Only Properties

Two CSS properties are handled entirely by the compositor thread, bypassing the main thread (see compositing layers):

  • transform — covers translation (translateX, translateY), scale, rotate, and skew
  • opacity — fades elements in and out

Because compositing runs on its own thread, these animations remain smooth even when JavaScript is blocking the main thread with heavy work. This is the most important rule in animation performance.

The practical translation: if you're moving an element, use transform: translateX() instead of changing left. If you're showing/hiding, use opacity instead of toggling display mid-transition.


CSS Transitions vs CSS Animations vs JS Animations

CSS Transitions are the simplest tool. Triggered by a class change or pseudo-state (like :hover), they interpolate between two property values over a duration. Use transitions when you have a clear start state → end state — opening a modal, expanding a card, highlighting a button.

CSS Animations (@keyframes) give you multi-step control with timing functions per step. Use them when you need more than a simple start-to-end — looping spinners, pulsing indicators, entrance sequences with staggered stages.

JavaScript animations (via requestAnimationFrame or libraries like GSAP) run on the main thread. They are more flexible — you can respond to physics, user input mid-animation, or drive complex sequences — but they compete with JS execution for main-thread time. Use JS when you need interruptibility (the user can cancel or redirect the animation mid-flight), physics (spring, inertia, friction), or choreography (sequencing multiple elements with staggered delays).

Quick decision guide:

  • Simple A→B: CSS transition
  • Multi-step or looping: CSS @keyframes
  • Physics, interruptibility, or complex sequencing: JS + rAF (or GSAP)

requestAnimationFrame

When you need JS-driven animation, requestAnimationFrame (rAF) is the correct tool. The browser calls your callback exactly once per frame, before paint, synchronized to the display refresh rate.

Never use setInterval or setTimeout for animation. They fire on their own schedule, independent of the browser's frame lifecycle, so you end up either updating the DOM multiple times between frames (wasted work — the extra updates are never painted) or missing frames entirely (jank). rAF fires exactly once per frame, right before paint — no wasted work, no missed frames.


The will-change Property

will-change hints to the browser that an element is about to be animated, allowing it to promote the element to its own compositor layer in advance.

This avoids a janky first frame where the browser scrambles to create the layer mid-animation. However, each promoted layer consumes GPU memory. Apply will-change only to elements that will actually animate, and remove it after the animation completes.

Blanket will-change: transform on every element is an anti-pattern that increases memory pressure without benefit.


Accessibility: prefers-reduced-motion

Some users experience motion sickness, vertigo, or seizures from animated content. The prefers-reduced-motion media query lets you respect their OS-level setting:

A duration of 0.01ms (not 0s) ensures the animation completes instantly — firing any animationend / transitionend listeners that other code might depend on. For critical motion that conveys information (like a loading spinner), consider replacing it with a static indicator instead.


Detecting Jank

Chrome DevTools' Performance panel shows frames as green bars along the timeline. Red bars or gaps are dropped frames. The Rendering tab's FPS meter shows live frame rate.

The Layers panel (DevTools → More Tools → Layers) shows every promoted compositor layer and its memory cost — useful for auditing will-change overuse.


The two-sentence version of all of this: animate transform and opacity, not geometry. When you need JS control, use requestAnimationFrame. Everything else — will-change, CSS vs JS choice, compositor layer auditing — is refinement on top of those two rules.


Let's Connect

© 2026 Naveen Karthik // Built with React & MUI