Animation Performance

Difficulty: Advanced

Animation performance is critical for delivering smooth, 60 frames-per-second experiences. When animations drop below 60fps, users perceive visual stuttering known as jank. Understanding how the browser renders content and which CSS properties trigger expensive operations is essential for building performant animations. The rendering pipeline consists of four main stages: Style calculation, Layout, Paint, and Composite - and your animation performance depends entirely on which of these stages your animated properties trigger.

The browser's rendering pipeline processes changes through these stages. Style calculation determines which CSS rules apply to each element. Layout (also called reflow) calculates the position and size of every element on the page. Paint fills in pixels - drawing text, colors, images, borders, and shadows. Compositing assembles the painted layers into the final image displayed on screen. The key insight is that properties like width, height, margin, padding, top, left, and font-size trigger layout, which causes the browser to recalculate the geometry of potentially the entire page. This is extremely expensive and nearly impossible to maintain at 60fps.

The two CSS properties that only trigger the composite stage are transform and opacity. Because compositing happens on the GPU (graphics processing unit), these properties can be animated at 60fps even on low-powered devices. Transform handles all movement (translate), sizing (scale), and rotation, while opacity handles visibility. By restricting animations to only these two properties, you ensure the browser can skip Layout and Paint entirely, using only the fast GPU compositor. This is called promoting an element to its own compositor layer.

The will-change property hints to the browser that a property is about to be animated, allowing it to pre-optimize by promoting the element to its own layer. Setting will-change: transform on an element before animating it avoids the one-frame delay that can occur when the browser creates a new layer on the fly. However, will-change consumes GPU memory - every promoted layer takes up VRAM. Never apply will-change to too many elements or leave it permanently on elements that are not actively animating. Set it just before the animation (e.g., on :hover of the parent) and remove it after.

For JavaScript-driven animations, requestAnimationFrame (rAF) is essential. It synchronizes your animation updates with the browser's refresh cycle, ensuring smooth 60fps rendering. Unlike setInterval or setTimeout, which fire at arbitrary times and can cause frame drops, rAF fires right before the next repaint. To avoid layout thrashing (the performance killer caused by reading and writing layout properties in a loop), batch all DOM reads together, then batch all DOM writes together. Reading a layout property (offsetWidth, getBoundingClientRect) forces the browser to recalculate layout synchronously - doing this repeatedly in a loop is called forced synchronous layout and is a major source of jank.

Profiling animation performance should be done with browser DevTools. Chrome's Performance panel shows frame rates, GPU memory, and identifies long frames. The Rendering tab has options to highlight layers, show paint flashing, and display FPS counters. Enable "Paint flashing" to see which areas are being repainted - ideally, only the animated element should flash green. The Layers panel shows which elements have been promoted to compositor layers and how much GPU memory they use.

Code examples

Good vs Bad Animated Properties

/* BAD: Triggers layout recalculation (expensive) */
.bad-animation {
  transition: width 0.3s, height 0.3s, top 0.3s, left 0.3s;
}
.bad-animation:hover {
  width: 300px;   /* Triggers Layout */
  height: 200px;  /* Triggers Layout */
  top: 50px;      /* Triggers Layout */
  left: 100px;    /* Triggers Layout */
}

/* GOOD: Only triggers compositing (GPU-accelerated) */
.good-animation {
  transition: transform 0.3s, opacity 0.3s;
}
.good-animation:hover {
  transform: translate(100px, 50px) scale(1.5);
  opacity: 0.8;
}

/* Instead of animating width/height, use scale */
.expand-bad {
  transition: width 0.3s;
}
.expand-bad:hover {
  width: 200%;  /* Triggers layout for entire page */
}

.expand-good {
  transition: transform 0.3s;
}
.expand-good:hover {
  transform: scaleX(2);  /* Only compositing, no layout */
}

/* Instead of animating top/left, use translate */
.move-bad  { transition: left 0.3s; }
.move-good { transition: transform 0.3s; }

.move-bad:hover  { left: 100px; }
.move-good:hover { transform: translateX(100px); }

Properties like width, height, top, and left trigger layout recalculation for potentially the entire page. Transform and opacity skip Layout and Paint, going directly to the GPU compositor. Always use transform as a replacement: translate instead of top/left, scale instead of width/height.

Using will-change Correctly

/* GOOD: Apply will-change just before animation */
.card {
  transition: transform 0.3s ease;
}
.card-container:hover .card {
  will-change: transform;
}
.card:hover {
  transform: translateY(-8px);
}

/* GOOD: Apply on elements that will definitely animate */
.modal {
  will-change: transform, opacity;
  /* Remove after animation via JavaScript */
}

/* BAD: Applying to everything wastes GPU memory */
* {
  will-change: transform; /* Never do this! */
}

/* BAD: Applying to too many static elements */
.every-list-item {
  will-change: transform; /* 1000 items = 1000 GPU layers */
}

/* GOOD: Apply via JavaScript when needed */
<script>
const element = document.querySelector('.animated');

element.addEventListener('mouseenter', () => {
  element.style.willChange = 'transform';
});

element.addEventListener('transitionend', () => {
  element.style.willChange = 'auto'; // Clean up
});
</script>

/* Alternative: Use transform: translateZ(0) to force layer */
.force-layer {
  transform: translateZ(0); /* Creates compositor layer */
  /* or */ backface-visibility: hidden;
}

will-change tells the browser to pre-create a compositor layer. Apply it only to elements that will actually animate, and remove it afterward. Each layer consumes GPU memory - promoting too many elements degrades performance instead of improving it.

requestAnimationFrame for Smooth JS Animations

// BAD: Using setInterval for animation
let position = 0;
setInterval(() => {
  position += 2;
  element.style.transform = `translateX(${position}px)`;
}, 16); // Tries to hit 60fps but drifts

// GOOD: Using requestAnimationFrame
let pos = 0;
function animate() {
  pos += 2;
  element.style.transform = `translateX(${pos}px)`;

  if (pos < 300) {
    requestAnimationFrame(animate);
  }
}
requestAnimationFrame(animate);

// BEST: Time-based animation with rAF
let startTime = null;
const duration = 1000; // 1 second
const distance = 300;  // 300px

function animateSmooth(timestamp) {
  if (!startTime) startTime = timestamp;
  const elapsed = timestamp - startTime;
  const progress = Math.min(elapsed / duration, 1);

  // Ease-out curve
  const eased = 1 - Math.pow(1 - progress, 3);

  element.style.transform = `translateX(${eased * distance}px)`;

  if (progress < 1) {
    requestAnimationFrame(animateSmooth);
  }
}
requestAnimationFrame(animateSmooth);

// Cancel animation when needed
const animId = requestAnimationFrame(animate);
cancelAnimationFrame(animId);

requestAnimationFrame syncs with the browser's refresh rate for smooth 60fps animation. Time-based animation calculates position from elapsed time rather than fixed increments, ensuring consistent speed regardless of frame rate. The easing function creates natural deceleration.

Avoiding Layout Thrashing

// BAD: Layout thrashing (read-write-read-write pattern)
const items = document.querySelectorAll('.item');
items.forEach(item => {
  const height = item.offsetHeight; // Read (forces layout)
  item.style.height = height * 2 + 'px'; // Write (invalidates layout)
  // Next iteration reads again, forcing another layout!
});

// GOOD: Batch reads, then batch writes
const items2 = document.querySelectorAll('.item');

// Batch all reads first
const heights = Array.from(items2).map(item => item.offsetHeight);

// Then batch all writes
items2.forEach((item, i) => {
  item.style.height = heights[i] * 2 + 'px';
});

// GOOD: Use requestAnimationFrame for writes
function updateLayout(elements) {
  // Read phase
  const measurements = elements.map(el => ({
    width: el.offsetWidth,
    height: el.offsetHeight
  }));

  // Write phase (batched in next frame)
  requestAnimationFrame(() => {
    elements.forEach((el, i) => {
      el.style.transform = `scale(${200 / measurements[i].width})`;
    });
  });
}

// BEST: Use CSS transforms instead of layout properties
// This avoids layout calculations entirely
element.style.transform = 'scale(2)'; // No layout triggered

Layout thrashing occurs when you interleave reading and writing layout properties, forcing the browser to recalculate layout on each read. Batch all reads together, then all writes. Better yet, use transforms instead of layout properties to avoid triggering layout entirely.

Key points

Concepts covered

GPU Acceleration, will-change, Compositing, Layout Thrashing, requestAnimationFrame