PAUL CHONGSenior Software Engineer

Animation Performance & the Compositor

2026-08-05 · 8 min read

Smooth animation requires the browser to produce a new frame every 16.67ms — 60 frames per second. Miss that budget and the frame is dropped, producing visible jank. The difference between a smooth animation and a janky one almost always comes down to which CSS properties you animate and whether the browser can hand work off to the GPU.

The 16ms budget

Screens refresh at 60Hz. For each frame, the browser must complete the full rendering pipeline — JavaScript, style recalculation, layout, paint, composite — in under 16.67ms. In practice you want main thread work under ~10ms to leave headroom for the browser's own overhead.

When a frame misses its deadline, it's dropped. One dropped frame in 60 is imperceptible. Consistently dropped frames — jank — makes the UI feel sluggish even if everything else is fast.

What you animate determines the cost

From the Browser Rendering Pipeline, there are three tiers of rendering cost:

Property examplesPipeline triggeredCost
width, height, margin, top, left, font-sizeLayout → Paint → CompositeHighest
color, background-color, box-shadowPaint → CompositeMedium
transform, opacity, filterComposite onlyLowest (GPU)

Animating left: 100px triggers layout on every frame. Animating transform: translateX(100px) skips layout and paint entirely — the GPU compositor moves the already-painted layer. The visual result is identical, the cost is not.

The rule: animate transform and opacity. Everything else risks dropping frames.

CSS Transitions

Interpolate between two states when a property changes.

.card {
  transform: translateY(0);
  transition: transform 300ms ease-out;
}

.card:hover {
  transform: translateY(-4px);
}

The browser watches for property changes (class toggle, pseudo-state, JS style assignment) and interpolates between the before and after values over the specified duration. You write only the start and end states; the browser handles every frame in between.

CSS transitions are the right tool for: hover effects, toggling open/closed states, entrance/exit animations triggered by class changes.

Limitations: one transition per property, A→B only (no mid-animation keyframes), no looping.

CSS Animations

Keyframe-based animations that run independently of state changes.

@keyframes spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

.spinner {
  animation: spin 1s linear infinite;
}

CSS animations run continuously without requiring a state trigger. They can loop, alternate direction, and define multiple keyframes at arbitrary positions:

@keyframes bounce {
  0%   { transform: translateY(0); }
  50%  { transform: translateY(-20px); }
  100% { transform: translateY(0); }
}

When animating transform or opacity, CSS animations run on the compositor thread — independent of main thread load. A heavy JavaScript computation won't drop these frames.

Use CSS animations for: loaders, spinners, attention-grabbing pulses, anything that loops or runs without user interaction.

will-change

Tell the browser to promote an element to its own compositor layer before animation begins.

When a CSS animation or transition starts, the browser needs to promote the element to a compositor layer. This promotion itself causes a one-frame stutter — visible as a pop on the first frame of the animation.

will-change signals that an element is about to animate, so the browser promotes it proactively:

.card {
  will-change: transform;
}

Now the layer exists before the animation starts, and the first frame is smooth.

From JavaScript, apply it just before the animation and remove it after to avoid holding the layer in memory:

el.style.willChange = 'transform';
el.classList.add('animate');

el.addEventListener('transitionend', () => {
  el.style.willChange = 'auto';
}, { once: true });

Do not apply will-change globally. Each promoted layer consumes GPU memory (VRAM). Promoting hundreds of elements degrades performance rather than improving it, especially on mobile. Apply it selectively, only to elements that actually animate.

The FLIP Technique

Animate layout changes smoothly using only compositor-friendly properties.

The problem: you want to animate an element from position A to position B, but B is determined by a DOM change (a card moving between lists, a grid reordering). You can't know B before the DOM updates, and once the DOM updates, the element snaps instantly — no animation.

FLIP solves this. The acronym: First, Last, Invert, Play.

First — record the element's current position before any DOM change:

const first = el.getBoundingClientRect();

Last — apply the DOM change. The element snaps to its new position. Record that:

list.appendChild(el);
const last = el.getBoundingClientRect();

Invert — calculate the delta between First and Last. Apply the inverse as a transform so the element visually appears to still be at First, even though it's now at Last in the DOM:

const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
el.style.transform = `translate(${deltaX}px, ${deltaY}px)`;

At this point the element is at Last in the DOM but looks like it's at First on screen.

Play — on the next frame, animate the transform back to zero. The element appears to travel from First to Last:

requestAnimationFrame(() => {
  el.style.transition = 'transform 300ms ease-out';
  el.style.transform = 'none';
});

The DOM change happens instantly. Visually, the element smoothly travels from A to B. Because we're only animating transform — a compositor-only property — every frame is GPU-rendered. Layout runs exactly once (at the Last step), not on every frame.

FLIP is the correct answer to any interview question about animating between layouts: drag-and-drop, list reordering, shared-element transitions, expanding cards.

requestAnimationFrame

Schedule a callback to run exactly once before the next browser paint.

function animate(timestamp) {
  const elapsed = timestamp - startTime;
  const progress = Math.min(elapsed / duration, 1);
  el.style.transform = `translateX(${progress * 100}px)`;

  if (progress < 1) requestAnimationFrame(animate);
}

const startTime = performance.now();
requestAnimationFrame(animate);

Why not setTimeout(fn, 16)?

  • setTimeout fires on a timer — not synchronized with the display refresh. It can fire mid-frame, causing a visual update that the user never sees (wasted work) or missing the frame entirely.
  • requestAnimationFrame fires at the start of each frame, synchronized with the display. Work done in the callback is guaranteed to appear in the next paint.
  • When the tab is hidden, requestAnimationFrame pauses automatically. setTimeout keeps firing, wasting battery and CPU.

requestAnimationFrame is the right tool for: physics-based animations (spring, momentum), scroll-linked effects, canvas animations, any animation that needs to compute values dynamically rather than interpolating between two fixed states.

Web Animations API

Imperative, browser-native animation with CSS-level performance and JS-level control.

const animation = el.animate(
  [
    { transform: 'translateY(0)', opacity: 1 },
    { transform: 'translateY(-20px)', opacity: 0 },
  ],
  {
    duration: 250,
    easing: 'ease-out',
    fill: 'forwards',
  }
);

await animation.finished; // promise resolves when animation ends

The Web Animations API gives you the control of requestAnimationFrame (play, pause, cancel, reverse, seek) with the performance characteristics of CSS animations — compositor-thread execution for transform and opacity.

Useful for: complex sequencing from JavaScript, animations that need to be paused or reversed programmatically, exit animations that need to complete before DOM removal.

// Animate out before removing
const anim = el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200 });
await anim.finished;
el.remove();

Diagnosing jank

When an animation feels wrong, open Chrome DevTools → Performance → record while reproducing the issue.

What to look for:

  • Long tasks (red triangles on the main thread track) — JS blocking the main thread past 50ms
  • Frames exceeding 16ms — any bar taller than the 16ms line in the frames track is a dropped frame
  • Layout events inside animation frames — purple "Layout" blocks during a scroll or animation means something is triggering layout per frame
  • Paint flashing — enable in Rendering panel; green overlays show what's being repainted. An animation that repaints the whole screen is expensive

Layer visualization: enable "Layer borders" in the Rendering panel to see compositor layers as colored borders. If an animating element doesn't have its own layer, it's not compositor-accelerated.

Decision guide

CSS TransitionCSS AnimationrequestAnimationFrameWeb Animations API
TriggerProperty changeAutomaticManualManual
Keyframes2 (start/end)MultipleComputed per frameMultiple
LoopingNoYesManualYes
JS controlLimitedLimitedFullFull
CompositorYes (transform/opacity)Yes (transform/opacity)Only if using transform/opacityYes (transform/opacity)
Best forHover, toggleLoaders, loopsPhysics, canvas, scroll-linkedSequencing, reversible, exit animations