CSS Animation Performance: GPU Acceleration and Compositing Layers
When animations stutter, 90% of the time you triggered reflow or repaint. This article explains CSS animation performance: animate only transform/opacity (compositing layers, no layout), proper and improper use of will-change, avoiding layout thrashing, and respecting prefers-reduced-motion for a smooth 60fps.
Key Points
- Topic Focus: React、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
CSS Animation Performance: GPU Acceleration and Compositing Layers
You wrote an animation that should be smooth, but on low-end devices and mobile it stutters like a slideshow. You suspect the device isn't powerful enough, but really your animation triggered the browser's reflow or repaint.
When animations stutter, 90% of the time it isn't a wrong frame-rate target—it's that you animated the wrong property.
Starting from the browser's pixel pipeline, this article explains why only transform and opacity deserve 60fps, and covers will-change, layout thrashing, and prefers-reduced-motion—the details that actually determine animation quality.
1. The Root Cause of Stutter: Reflow and Repaint
To paint a frame onto the screen, the browser walks a complete "pixel pipeline":
JS → Style → Layout → Paint → Composite
- Style: recompute selector matches and determine each element's final styles.
- Layout (reflow): recompute each element's geometry—position and size—based on the box model.
- Paint (repaint): paint elements into pixels across multiple layers.
- Composite: the GPU merges these layers by z-order and transform into the final frame.
Along this pipeline, the earlier the stage, the more expensive it is. Layout is a domino effect—one element's width changes and siblings, parents, even the entire document's geometry may need recomputing. Paint isn't cheap either, especially for large areas with shadows or blur filters. Composite is the cheapest, since it's just the GPU doing matrix math on existing layers.
The essence of animation stutter is: on every frame, you repeatedly trigger Layout or Paint.
2. The Pixel Pipeline and the Golden Rule of "Composite Only"
60fps means a 16.6ms budget per frame; after the browser's own overhead, you're left with about 10ms. Running a full Layout + Paint inside those 10ms makes a stable 60fps essentially impossible.
Fortunately, two properties naturally hit only Composite:
transform(translate / scale / rotate / skew)opacity
They're fast because the browser (when conditions are met) opens a separate composited layer for elements using these two properties and hands it directly to the GPU. When animating them, the main thread isn't involved, layout and paint are skipped, and the GPU just runs affine transforms or alpha blending on an already-painted "texture"—per-frame cost is constant, independent of the element's area.
This is the truth of so-called GPU acceleration / hardware acceleration: it isn't magic in the property itself, but that the property promotes the element to an independent layer, so subsequent animation happens only at the composite stage.
So the first principle of animation is: animate only transform and opacity. Need to move? Use transform: translate(). Need to scale? transform: scale(). Need to show/hide? opacity.
3. Good Animation vs. Bad Animation: Code Comparison
First, the bad example. One of the most common mistakes—using left/top for movement:
/* Bad: triggers Layout every frame; the main thread explodes */
.box {
position: absolute;
left: 0;
transition: left 0.3s ease;
}
.box.move { left: 200px; }
Properties like left/top/margin/width/height change the element's geometry, forcing the browser to re-Layout every frame—parents and siblings reflow along with it, then Paint and Composite. On low-end devices, a single animation like this can drop the frame rate below 20fps.
The correct approach—use transform:
/* Good: Composite only; the GPU takes over; stable 60fps */
.box {
position: absolute;
left: 0;
transform: translateX(0);
transition: transform 0.3s ease;
will-change: transform;
}
.box.move { transform: translateX(200px); }
Compare:
- Geometric properties (
left/top/margin/width/height/padding): trigger Layout → Paint → Composite; main thread stalls. color/background/border-color/box-shadow: skip Layout but still Paint; large areas still drop frames.transform/opacity: Composite only; the GPU does the work; stable.
A rule of thumb: scan your motion, and whenever something animates a property other than transform and opacity, stop and ask—can it be rewritten with a compositing property? In 95% of cases, yes.
For example, "a progress bar widening": a beginner writes width: 0 → 100%, a veteran writes transform: scaleX(0 → 1) with transform-origin: left center. Same effect, vastly different performance.
4. will-change: A Powerful Tool When Used Well, a Burden When Abused
will-change is a "hint" API: you tell the browser in advance that "this element is about to change," giving it a chance to create a compositing layer and preallocate resources ahead of time so the first frame doesn't stutter.
.modal-card {
will-change: transform, opacity;
}
But will-change has a cost. It pins the element to a compositing layer, consuming extra GPU memory; each layer maintains a texture, and too many layers cause memory to balloon and compositing itself to slow down (increased overdraw). Adding will-change to every list item, or globally, is the most common misuse:
/* Big mistake: every element pins a compositing layer; memory and compositing overhead explode */
li { will-change: transform; }
There are three correct practices:
- Apply it only on elements about to animate, then remove it. You can pair it with JS: add it on
mouseenter, remove it ontransitionend. - Don't leave it on long-term. It's a hint for "imminent change," not a performance toggle. An element that never moves yet keeps
will-changeis pure waste. - Keep the scope small, declaring only properties that will actually change, not
will-change: all(which is neither standard nor meaningful).
A simple test: will-change solves the cold-start problem of "a hitch on the first frame," not the "stutters throughout the whole animation." If it stutters throughout, the problem is in the property and layout, not in will-change.
5. Layout Thrashing: The Invisible Killer of Interleaved Read/Write
Composited-layer animations are lightweight on their own, but if you read DOM geometry with JS on every frame of the animation, you trigger forced synchronous layout (reflow), dragging the main thread to a halt:
// Bad: read one, write one—each loop iteration forces the browser to compute layout immediately
boxes.forEach((box) => {
const width = box.offsetWidth; // read → triggers layout
box.style.width = width + 10 + 'px'; // write → invalidates layout
});
The browser normally batches write operations and defers them to the next frame to compute layout once. But if you insert a "read" in between (offsetWidth / getBoundingClientRect / scrollTop / clientHeight, etc.), the browser has to flush all current writes and compute layout immediately to give you an accurate value. Loop N times, and you force N reflows. This is layout thrashing.
The correct approach is batch reads, batch writes:
// Good: read all first, then write all together; layout is computed once
const widths = boxes.map((box) => box.offsetWidth); // read all
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px'; // write all
});
Alternatively, use requestAnimationFrame to push writes to the next frame; for more complex cases, libraries like FastDOM can queue reads and writes. In React, avoid reading DOM geometry during render (already an anti-pattern)—use ref + useLayoutEffect, read-only, or cache the value after reading and write asynchronously.
In short: separate reads and writes; never interleave.
6. prefers-reduced-motion: Accessibility Is Not Optional
Some users (vestibular dysfunction, motion sickness, low vision) are sensitive to animation; continuous movement, flicker, and large-scale zooming can make them dizzy or nauseous. Operating systems and browsers provide a "reduce motion" preference toggle, exposed in CSS through the prefers-reduced-motion media query.
Respecting it is an engineer's duty, not a nice-to-have:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Compress the duration to nearly zero and set iteration count to 1: the animation "completes instantly," functionality is preserved, but no motion is produced. Note: don't display: none the animation, and don't opacity-hide the element—that breaks functional state transitions. What degrades is the "motion," not the "result."
An advanced approach is tiering: keep core interactive animations (state-change feedback) at very short durations, and turn off purely decorative animations (entry, parallax, background loops) entirely in reduce mode.
You can also read this preference in JS: window.matchMedia('(prefers-reduced-motion: reduce)').matches, used to decide whether to launch heavy effects like GSAP or WebGL particles.
7. Debugging: See Every Frame with DevTools
Writing the animation isn't the end—you need tools to verify.
-
Performance panel: Cmd/Ctrl + E to record a segment, then inspect the frame sequence. Green bars are 60fps; yellow/red bars are dropped frames. Click a frame and the flame graph below shows where its time went—Layout? Paint? JS? If Layout and Paint recur on every frame, you animated the wrong property.
-
Layers panel (enable in More tools): visualize all compositing layers—see which elements were promoted to independent layers, how many layers there are, how large the textures are, and whether overdraw is severe. If an element that doesn't need animation is inexplicably promoted (e.g.,
will-changesprinkled everywhere, ortransform: translateZ(0)used liberally), it's obvious here. -
Rendering panel: check Paint flashing and repainted regions of the page flash green; check Layer borders and FPS meter to watch frame rate and compositing-layer boundaries in real time. This is the fastest way to diagnose "why does the whole screen flash when I animate?"
Real-world diagnosis flow: Performance to check for drops → flame graph for the cause (Layout / Paint / JS) → Paint flashing for the repaint scope → Layers for layer count and texture size. After these four steps, 90% of animation performance issues have nowhere to hide.
Summary
- Animate only
transformandopacity: they're compositing properties, hit only Composite, the GPU takes over, stable 60fps. Other properties all carry layout or paint costs. - Geometric properties are off-limits for animation:
left/top/margin/width/heighttrigger reflow every frame, guaranteed stutter. Rewrite movement astranslateand scaling asscale. will-changeis a hint, not a toggle: use it briefly on elements about to change, then remove it; global abuse just eats memory and slows compositing.- Separate reads and writes, say goodbye to layout thrashing: never interleave read/write of DOM geometry in JS—batch reads, batch writes, or push to
requestAnimationFrame. - Respect
prefers-reduced-motion, verify frame rate with DevTools: accessible degradation is an engineer's duty; the Performance + Layers + Paint flashing trio leaves no frame's cost hidden.
The one-liner: Animation smoothness isn't piled on—it's saved by "doing less." The best frame is the one where the browser never reflowed or repainted at all.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.