---
title: "The Animation Tax: Why Animating Width Costs 273ms of Layout Per 1.8 Seconds"
published: true
description: "Real Chrome tracing benchmarks: animating width on 3,000 elements triggers 110 layout passes in 1.8s (273ms of layout work); transform triggers zero."
tags: webdev, css, javascript, performance
canonical_url: https://www.jslet.com/animation-tax-real
cover_image: https://www.jslet.com/og-image.png
---
Two lines of CSS. Identical visual motion on your screen. Wildly different rendering bills.
We traced Chrome 136's rendering pipeline to measure what animating layout properties actually costs under the hood[cite: 1]. The result: animating `width` on 3,000 elements triggered **110 layout passes (273ms of layout work plus 213ms of paint)** in just 1.8 seconds[cite: 1]. The exact same animation built with `transform` triggered **zero layout passes**[cite: 1].
One property swap, and the layout tax drops to zero[cite: 1].
---
### Executive Summary & Benchmark Data
We benchmarked 3,000 inline-block elements animated via `requestAnimationFrame` for 1.8 seconds in Chrome 136 (headless, Windows 11, desktop-class CPU) and collected rendering pipeline events via Chrome DevTools Protocol (CDP) Tracing (`devtools.timeline`)[cite: 1]:
| Animated Property | Layout Passes | Layout Time | Paint Time | Verdict |
| :--- | :---: | :---: | :---: | :--- |
| `transform: translateX()` | **1** (initial) | **~0.01 ms / frame** | 49.6 ms | 🟢 Compositor only[cite: 1] |
| `width` | **110** | **273.4 ms** | 213.6 ms | 🔴 Reflow every frame[cite: 1] |
*(Data source: jslet animation lab[cite: 1])*
---
### Tax 1 — The Layout Invoice: Wrong Property = Reflow Every Frame
Animation is the most expensive place to trigger layout because it triggers it *repeatedly*[cite: 1]. Every frame of a `width`, `top`, `height`, or `margin` animation invalidates geometry for the target element, its descendants, its parent, and its surrounding siblings[cite: 1]. The browser must synchronously recalculate geometry before painting[cite: 1].
1. **One reflow per frame, always[cite: 1].** Over 110 frames of animation, `width` pays the full layout tax 110 times[cite: 1]. The `transform` version pays layout once on the initial paint, then hands off the rest of the run to the GPU compositor thread[cite: 1].
2. **4.5ms per frame burned before your JS runs[cite: 1].** At 3,000 elements, each frame spends ~2.5ms on layout and ~2.0ms on paint[cite: 1]. On a standard 60Hz display (16.7ms frame budget), that consumes 27% of your budget purely on browser layout machinery[cite: 1]. Throw in React/Vue state reconciliation, scroll handlers, or store updates, and dropped frames are guaranteed[cite: 1].
3. **The cost scales with time[cite: 1].** 273ms of layout in 1.8 seconds means a 10-second looping animation blocks the main thread with 1.5 seconds of pure geometry calculation[cite: 1].
css
/* ❌ The 273ms layout tax (triggers reflow + repaint every frame) */
@keyframes slide-open {
from { width: 0; }
to { width: 300px; }
}
/* ✅ 0ms layout tax (runs purely on the GPU compositor thread) */
@keyframes slide-open {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
---
### Tax 2 — The Paint Double-Bill: Layout Animations Re-Paint What They Reflow
Layout isn't the only line item[cite: 1]. When an element changes its physical dimensions, the affected pixel regions must be rasterized and painted again[cite: 1].
* In our run, `width` forced **213.6ms of paint work** (4.3× the paint cost of `transform`)[cite: 1].
* `transform` animations are composited: the browser creates a pre-painted bitmap texture and repositions/scales it directly on the GPU, keeping CPU paint cost flat[cite: 1].
---
### Tax 3 — The Mobile Surcharge: Every Number Multiplied by 4–6×
A mid-range smartphone's single-core CPU speed is roughly 4–6× slower than a desktop developer machine[cite: 1].
* The 4.5ms/frame desktop rendering time scales to **~27ms/frame on mobile**[cite: 1].
* That is **over budget on a 60Hz mobile screen before your JavaScript executes a single instruction**[cite: 1]. An animation that looks silky on a MacBook Pro will visibly stutter on a mid-range Android phone[cite: 1].
---
### The Traps: Fill-Mode, Replay, and `will-change`
#### 1. Fill-Mode: The Blinking Entrance
Without `animation-fill-mode: forwards`, an element resets to its initial pre-animation state the moment the animation finishes (e.g., a fade-in flashes visible then snaps back to invisible)[cite: 1].
css
/* ❌ Snaps back to 0 opacity after 300ms */
.toast-enter {
animation: fadeIn 300ms ease-out;
}
/* ✅ Holds the final keyframe state */
.toast-enter {
animation: fadeIn 300ms ease-out forwards;
}
#### 2. Replay: Re-applying the Class Does Not Restart
Re-adding the same CSS animation class does not re-trigger the keyframe sequence in the browser engine[cite: 1]. The reliable pattern is to remove the animation, trigger a single intentional reflow via DOM property access, and re-apply it[cite: 1]:
javascript
function replayAnimation(element) {
element.style.animation = 'none';
// Force a single synchronous reflow flush
void element.offsetHeight;
element.style.animation = 'fadeIn 300ms ease-out forwards';
}
#### 3. `will-change`: A Targeted Hint, Not a Blanket Rule
`will-change: transform` prompts the browser to promote an element to its own compositor layer ahead of time[cite: 1]. However, **every promoted layer consumes GPU VRAM**[cite: 1]. Overusing `will-change` across large lists causes GPU memory bloat and scroll stutter[cite: 1]. Use it on at most 1–2 key elements and remove it once the motion finishes[cite: 1].
---
### Property Decision Matrix
| Animated Property | Render Pipeline Stage | Production Verdict |
| :--- | :--- | :--- |
| `transform` (`translate`, `scale`, `rotate`) | Compositor thread only[cite: 1] | 🟢 **Always Safe**[cite: 1] |
| `opacity` | Compositor thread only[cite: 1] | 🟢 **Always Safe**[cite: 1] |
| `width`, `height` | Layout ➔ Paint ➔ Composite[cite: 1] | 🔴 Replace with `transform: scale()`[cite: 1] |
| `top`, `left`, `margin`, `padding` | Layout ➔ Paint ➔ Composite[cite: 1] | 🔴 Replace with `transform: translate()`[cite: 1] |
| `color`, `background-color` | Paint ➔ Composite[cite: 1] | 🟡 Paint-only; use sparingly[cite: 1] |
| `filter`, `box-shadow` | Paint + heavy GPU raster[cite: 1] | 🟡 Heavy GPU tax; avoid animating on large DOM trees[cite: 1] |
---
### 5-Step Animation Performance Audit
1. **Grep your stylesheets:** Search for `@keyframes` and `transition` declarations containing `width`, `height`, `top`, `left`, `margin`, or `padding`[cite: 1].
2. **Replace with compositor equivalents:**
* `width` / `height` ➔ `transform: scaleX()` / `scaleY()` (set `transform-origin` appropriately)[cite: 1]
* `top` / `left` / `margin` ➔ `transform: translate3d(x, y, 0)`[cite: 1]
3. **Verify fill-modes:** Ensure entrance animations specify `forwards` (or `both` when delays are used)[cite: 1].
4. **Audit `will-change`:** Remove blanket `will-change` rules from component libraries[cite: 1].
5. **Measure in DevTools:** Open Chrome DevTools ➔ **Performance** tab ➔ record the animation. **The Layout track should show zero bars during motion**[cite: 1].
---
### Interactive Playground & Resources
To experiment with compositor-safe keyframes without calculating matrix transforms by hand, try our free client-side tool:
* **[CSS Animation Generator — jslet.com](https://www.jslet.com/css-animation-generator)** *(Zero tracking, runs 100% in-browser)*[cite: 1]
* Companion deep dive: **[The Layout Tax: Why Your 10,000-Element Grid Costs Users 8ms Per Frame](https://www.jslet.com/layout-tax-real)**[cite: 1]
* Compositor specifications: [web.dev animations guide](https://web.dev/articles/animations-guide)[cite: 1]
What properties or animation reset quirks have tripped up your team on mobile? Share your notes below!
Top comments (0)