UI/UX Atlas
Motion & Animation Intermediate

Animation Performance & the Compositor Pipeline

Understand how browsers render motion, which CSS properties stay off the main thread, and why compositor-only animation is non-negotiable for smooth UIs.

7 min read

The full lesson

Smooth animation is more than a visual nicety — it is a trust signal. Janky, stuttering motion tells users something is wrong, even when the underlying logic is fine. The difference between a 60 fps animation and a janky one almost always comes down to two questions: which CSS properties are you animating, and which thread is doing the work? Understanding the browser’s rendering pipeline gives you a clear, mechanical answer every time.

This lesson maps the browser’s rendering stages, identifies the small set of properties that stay safely off the main thread, and shows how modern CSS and JavaScript tools make the fast path the easy path.

The Browser’s Rendering Pipeline

Every frame a browser draws passes through up to five sequential stages. Skipping earlier stages is always faster — and that is exactly what compositor-only animations do.

StageWhat happensCost
JavaScript / StyleRuns JS, recalculates CSS property valuesBlocks main thread
LayoutCalculates geometry: widths, heights, positions of every affected elementExpensive — cascades through the DOM
PaintFills pixel colors for each layerModerate — repaints affected regions
CompositeAssembles GPU-backed layers into the final frameCheap — runs on a dedicated compositor thread

When you animate top, left, width, height, margin, or padding, the browser must run Layout on every frame. Any adjacent element that could be affected gets recalculated too. This is called layout thrashing — forced geometry recalculation on every frame — and it is the leading cause of jank.

When you animate transform or opacity, the browser hands the entire animation to the compositor thread. The compositor thread exists specifically to assemble layers and does not touch the main thread. It keeps running even when JavaScript is busy. That is how a complex React re-render and a silky smooth slide-in animation can coexist.

Compositor-Only Properties: The Short List

The rule is simple enough to memorize: animate only transform and opacity.

Every common UI motion effect maps to one or both of these:

  • Slide in / outtranslate(X, Y) via transform
  • Scale up / downscale() via transform
  • Rotaterotate() via transform
  • Fade in / outopacity
  • Blur / filter transitionsfilter is partially compositor-accelerated in most browsers, but less reliable; prefer opacity where possible

What does not belong in an animation:

/* These trigger layout on every frame — avoid */
.bad {
  transition: width 300ms ease-out;
  transition: height 300ms ease-out;
  transition: top 300ms ease-out;
  transition: left 300ms ease-out;
  transition: margin 300ms ease-out;
  transition: padding 300ms ease-out;
}

/* These trigger paint on every frame — avoid in hot paths */
.also-bad {
  transition: background-color 300ms ease-out; /* paint-heavy */
  transition: box-shadow 300ms ease-out;       /* paint-heavy */
}

/* Compositor-only — prefer these */
.good {
  transition: transform 300ms cubic-bezier(0, 0, 0.3, 1);
  transition: opacity 300ms cubic-bezier(0, 0, 0.3, 1);
}

Promoting Elements to Their Own Layer

The compositor works with layers — GPU-backed bitmaps (images held in GPU memory) that can be translated, scaled, and combined independently. Normally the browser decides which elements get their own layer. You can give it a hint with will-change.

.drawer {
  will-change: transform;
}

will-change: transform tells the browser: “this element is about to be animated — please give it its own compositing layer now.” The browser allocates GPU memory ahead of time, so the first frame of the animation does not pay a promotion cost.

Use it sparingly. Every layer costs GPU memory — roughly 4 bytes per pixel. Promoting a 400 × 800 pixel panel costs about 1.2 MB of GPU RAM. Promote everything indiscriminately and you will exhaust GPU memory on mobile devices. The browser then falls back to software rendering, which is slower than no promotion at all.

When to use will-change:

  • On elements that animate frequently and interactively (drawers, sheets, tooltips that open on hover)
  • Applied just before the animation starts (toggle it with a class) and removed after it ends
  • Not as a global performance “hack” applied to everything

The outdated pattern was transform: translateZ(0) — a trick that forced layer promotion by giving an element a fake 3D transform. It worked, but it promoted elements permanently, wasted memory, and created confusing visual stacking side effects. will-change is the correct, intention-signaling modern replacement.

Do

Use will-change: transform or will-change: opacity on specific interactive elements that animate frequently. Add it via a class applied just before the animation starts, and remove it once the animation ends. This gives the browser the promotion signal with minimal GPU overhead.

Don't

Apply will-change: transform globally or as a catch-all performance fix. Do not use the old transform: translateZ(0) hack — it permanently promotes elements to their own layer, wastes GPU memory, and creates unintended stacking contexts that break z-index behavior. Do not set will-change on elements that rarely animate.

Measuring Frame Performance in Practice

Profiling is the only way to know whether you actually have a problem. Chrome DevTools’ Performance panel shows frame-by-frame rendering cost. The Layers panel shows the current compositor layer tree.

A practical debugging workflow:

  1. Open DevTools and switch to the Performance tab.
  2. Enable CPU throttling (4x slowdown) to simulate a mid-range Android device. Always profile the 50th-percentile user, not your MacBook Pro.
  3. Record the animation you are debugging for 2–3 seconds.
  4. Look at the Main thread flame chart. Long purple bars are Layout; long green bars are Paint. Long bars during an animation mean you are animating the wrong property.
  5. Look at the Frames section at the top. Any frame taking longer than 16.6 ms (the 60 fps budget) shows as a red or yellow bar.
  6. Open the Layers panel to see which elements are on compositor layers and how much GPU memory each one costs.

The Rendering drawer in DevTools also exposes “Paint flashing” (green overlays on repainting regions) and “Layer borders” (blue borders around compositor layers). Toggle these on while scrolling or triggering animations for instant visual feedback about your rendering cost.

CSS @property and Paint Worklets

Sometimes you genuinely need to animate a value that is not compositor-safe — for example, animating a color stop inside a CSS gradient. The modern approach is to register a custom CSS property using @property, give it a type, and animate that property instead of the gradient directly.

@property --gradient-stop {
  syntax: '<color>';
  initial-value: oklch(60% 0.2 250);
  inherits: false;
}

.button {
  background: linear-gradient(135deg, var(--gradient-stop), oklch(40% 0.15 290));
  transition: --gradient-stop 300ms ease-out;
}

.button:hover {
  --gradient-stop: oklch(70% 0.25 200);
}

Because the custom property is registered with a syntax type, the browser knows how to interpolate (smoothly step between) it. Without @property, custom properties are treated as plain strings and cannot be animated. The gradient itself is still rebuilt on each paint, but the interpolation is driven by the registered property value, which can be transitioned.

This is a narrow but powerful escape hatch. Use it when the visual effect genuinely requires animating a non-transform value and a transform-only workaround would be more complex than the problem it solves.

content-visibility and Animation Performance

The CSS content-visibility: auto property (supported in all major browsers as of 2024) lets the browser skip layout and paint for off-screen content. For long, scroll-driven pages with many animated cards or sections, this is a meaningful win — elements outside the viewport do not consume layout budget.

One thing to watch: elements skipped by content-visibility are also not running their animations. When they scroll into view, the browser promotes them and starts rendering. In most cases that is the desired behavior. If you have animations that should already be in progress when an element enters the viewport, you may need to reset and restart them via an Intersection Observer.

The JavaScript Animation Layer: Web Animations API and requestAnimationFrame

For complex, programmatic animations that CSS alone cannot express, JavaScript has two compositor-aware paths.

Web Animations API (WAAPI) is the modern standard for imperative animation. Animations on transform and opacity stay off the main thread, just like CSS transitions.

element.animate(
  [
    { transform: 'translateY(20px)', opacity: 0 },
    { transform: 'translateY(0)',    opacity: 1 }
  ],
  {
    duration: 250,
    easing: 'cubic-bezier(0, 0, 0.3, 1)',
    fill: 'forwards'
  }
);

WAAPI supports .pause(), .reverse(), .cancel(), and playbackRate. That makes animations composable and interruptible without managing requestAnimationFrame loops manually.

requestAnimationFrame (rAF) is the fallback for anything WAAPI cannot express. rAF runs on the main thread, so it carries the same jank risks as CSS layout animations if you touch layout-triggering properties inside the callback. The rule inside a rAF callback: read all geometry first (all your getBoundingClientRect calls), then write styles. Mixing reads and writes forces synchronous layout (also called a forced reflow) — one of the worst causes of main-thread jank.

Motion Tokens and Performance as a System Property

Individual performance fixes are local wins. Encoding them into your design system makes them structural. When your motion token system defines that all panel transitions use transform and opacity — and your component library enforces those tokens — individual engineers stop having to reinvent (and occasionally get wrong) this decision.

A practical approach:

{
  "motion": {
    "animated-property": {
      "position": { "$value": "transform", "$type": "string" },
      "visibility": { "$value": "opacity",   "$type": "string" }
    }
  }
}

In a design system audit, “animates compositor-only properties” should be a checkbox on every new motion-animated component — the same way “has a prefers-reduced-motion fallback” is a checkbox. Both are performance and accessibility standards, not optional polish.

The outdated habit was treating translateZ(0) as a silver bullet, applying it everywhere, and calling it done. The modern practice goes deeper: understand which pipeline stage each property triggers, measure with real profiling on mid-range hardware, and use will-change intentionally rather than defensively.