CSS Animation Performance: What's Cheap and How to Fix Jank
Which CSS properties animate cheaply, what causes dropped frames, how CSS compares to JS and rAF, and how to measure it in Chrome and Firefox DevTools.

Cheap CSS animations change only transform and opacity. The browser can handle those two properties at the compositing stage of its rendering pipeline, so it doesn't need to recalculate layout or repaint pixels on every frame. Almost everything else, including top, left, width, height and box-shadow, forces layout or paint work. That work runs on the main thread, competes with your JavaScript, and is the usual cause of jank.
If an animation stutters, the fix is nearly always one of three things: swap the animated property for a transform or opacity equivalent, give the element its own layer with will-change, or, for genuinely complex motion, move it to JavaScript with requestAnimationFrame(). The sections below show how to do each and how to confirm in DevTools that it worked.
What's cheap to animate and why#
The browser renders a frame in stages: it resolves styles, lays out boxes, paints pixels, then composites layers into the final image. A property's animation cost depends on how early in that chain it lands. Change an element's width and the browser must redo layout for that element and possibly its neighbours, repaint, then composite. Change its opacity and, if the element is on its own layer, the browser only composites.
The web.dev animation guide boils this down to two properties you should prefer, plus a rule for everything else: before animating any other property, determine its impact on the rendering pipeline, and avoid anything that triggers layout or paint unless it's absolutely necessary.
| Goal | Cheap property | Expensive alternative |
|---|---|---|
| Move an element | transform: translate(...) |
top, left, margin |
| Rotate | transform: rotate(...) |
anything that changes box geometry |
| Resize | transform: scale(...) |
width, height, padding |
| Show or hide | opacity |
height: 0, toggling display |
| Soften or remove a shadow | opacity on a second element that carries the shadow |
animating box-shadow directly |
Browser support isn't a reason to hesitate. transform, opacity and will-change all have good cross-browser support.
Simple CSS animation effects that stay cheap#
These four cover most UI motion: slide in, spin, pulse, fade. They're taken from the web.dev guide and only touch transform or opacity.
Slide something into view:
.animate {
animation: slide-in 0.7s both;
}
@keyframes slide-in {
0% {
transform: translateY(-1000px);
}
100% {
transform: translateY(0);
}
}
Spin it a full turn:
.animate {
animation: rotate 0.7s ease-in-out both;
}
@keyframes rotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
Shrink and grow back:
.animate {
animation: scale 1.5s both;
}
@keyframes scale {
50% {
transform: scale(0.5);
}
100% {
transform: scale(1);
}
}
Fade out and back in:
.animate {
animation: opacity 2.5s both;
}
@keyframes opacity {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
You can combine functions in one value, such as transform: translateX(500px) rotate(360deg) scale(1.4). It's still a single composited property.
What causes jank in CSS animations#
Jank is dropped frames. Each frame has to be ready before the display refreshes. If the browser is still doing layout or paint when that deadline hits, the frame is skipped and the motion visibly stutters.
The classic offender is moving an element with top and left. The web.dev guide compares the two approaches directly. Don't do this:
.box {
position: absolute;
top: 10px;
left: 10px;
animation: move 3s ease infinite;
}
@keyframes move {
50% {
top: calc(90vh - 160px);
left: calc(90vw - 200px);
}
}
Do this instead. The element keeps its starting offsets, and the movement comes from transform:
.box {
position: absolute;
top: 10px;
left: 10px;
animation: move 3s ease infinite;
}
@keyframes move {
50% {
transform: translate(calc(90vw - 200px), calc(90vh - 160px));
}
}
In the guide's measurements with Chrome's FPS meter, the top/left version drops 50% of its frames, while the transform version drops only 1%. Same visual result, same duration.
The second common cause is paint cost. Anything involving a blur, like a box-shadow, takes the browser longer to paint than a flat rectangle. That cost isn't visible in the CSS, so a shadow that looks harmless can be the thing eating the frame budget. The third is main-thread contention: an animation that needs layout or paint shares the main thread with your JavaScript, so a busy script and a cheap-looking animation can still stutter together.
How to test CSS animation performance in DevTools#
You can't tell by reading the CSS whether an animation is expensive. Measure it.
In Chrome:
- Open the Performance panel and record while the animation runs.
- Check the Summary tab. A nonzero value for Rendering suggests the animation is causing layout work.
- Open the Rendering tab and enable the FPS meter checkbox. Watch the Frames label at the top; it reads something like
50% 1 (938 m) dropped of 1878. A smooth animation shows a high percentage, such as 99%. - In the same Rendering tab, select Paint Flashing and move the pointer around. Repainted areas light up. If the whole screen flashes, or regions you didn't expect, use the paint profiler to find the property responsible.
In Firefox:
- Open the Performance panel, record, then inspect the Waterfall tab. Entries for Recalculate Style mean the browser went back to the start of the rendering waterfall for the animation.
- In Settings, add a Toolbox button for Toggle paint flashing, turn it on, then move the mouse or scroll to see repainted areas.
The Frames label is easy to misread, so here's a small script that pulls the numbers out and prints the per-frame time budget at common refresh rates:
// Parse Chrome's FPS meter "Frames" label and show the frame budget
const label = "50% 1 (938 m) dropped of 1878";
const match = label.match(/\((\d+) m\) dropped of (\d+)/);
const dropped = Number(match[1]);
const total = Number(match[2]);
const rendered = total - dropped;
console.log(`rendered ${rendered} of ${total} frames`);
console.log(`dropped ${((dropped / total) * 100).toFixed(1)}% of frames`);
for (const hz of [60, 90, 120]) {
console.log(`${hz} Hz display: ${(1000 / hz).toFixed(2)} ms per frame`);
}
Force a layer with will-change#
Elements on their own compositor layer can be repainted without repainting the rest of the page. Browsers usually decide layer promotion well on their own, but you can force it:
body > .sidebar {
will-change: transform;
}
Two cautions. First, the specification suggests only putting will-change in your stylesheet for elements that are always about to change, such as a sidebar the user slides in and out. Second, for elements that change rarely, add the property from JavaScript when a change is likely, give the browser enough time to prepare, and remove it when the change has stopped:
const panel = document.querySelector(".panel");
panel.addEventListener("pointerenter", () => {
panel.style.willChange = "transform";
});
panel.addEventListener("animationend", () => {
panel.style.willChange = "auto";
});
For a browser without will-change support, transform: translateZ(0) forces layer creation as a fallback. Treat both as a last resort. The guide's advice is to use will-change sparingly and only once you've measured a problem. The MDN reference for will-change covers the accepted values.
CSS vs JavaScript animation performance#
Two comparisons come up constantly, and MDN's article on CSS and JavaScript animation performance settles both.
Transitions vs animations. There's no performance difference. A transition interpolates from the current style to an end state, and a new transition starts from wherever the element currently is. An animation runs a set of keyframes with optional intermediate points. Pick whichever matches the motion you need.
CSS vs requestAnimationFrame(). When both run on the main thread, they perform about the same. CSS animations resample element styles on the main thread before each repaint, which is almost exactly what a rAF callback does. MDN's test animates 1000 <div> elements with transform and toggles between the two approaches; in Firefox the frame rates come out very close. That's also why JavaScript libraries like GSAP and Velocity.js can claim to match or beat native CSS.
The advantage of CSS shows up when the animated properties don't trigger reflow or repaint. With transform on a promoted layer, browsers can move the sampling off the main thread and run it on the GPU, which MDN describes as better performance and efficiency, especially on mobile. Firefox calls this Off Main Thread Animation (OMTA). You can toggle it in about:config with layers.offmainthreadcomposition.async-animations, and show an on-screen frame counter with layers.acceleration.draw-fps. With OMTA on, the CSS side of the test runs at a significantly higher FPS than the rAF side.
MDN's conclusion is the one I'd give: use CSS transitions and animations wherever you can, and fall back to JavaScript only when the motion is genuinely complex. One thing both share is that CSS animations and rAF callbacks pause when the tab goes to the background.
If you do go the rAF route, keep the per-frame work tiny and write a single transform string. This is the same calculation MDN's test uses, run offline for a few timestamps so you can see what each frame would set:
// What a rAF loop would assign to element.style.transform at a few points in time
const duration = 6000; // ms, matches "animation: animate 6s linear infinite alternate"
const translateX = 500;
const rotate = 360;
const scale = 1.4 - 0.6;
function transformAt(elapsedMs) {
const progress = (elapsedMs / duration) % 2; // 0..2 is one forward and one reverse pass
const t = progress >= 1 ? 2 - progress : progress;
const x = (t * translateX).toFixed(0);
const deg = (t * rotate).toFixed(0);
const s = (0.6 + t * scale).toFixed(2);
return `translateX(${x}px) rotate(${deg}deg) scale(${s})`;
}
for (const ms of [0, 1500, 3000, 4500, 6000, 9000, 12000]) {
console.log(`${String(ms).padStart(5)} ms -> ${transformAt(ms)}`);
}
Common mistakes and gotchas#
- Animating
top/leftbecause the element is already positioned. Keepposition: absoluteand the static offsets, and put the movement intransform, as in the example above. - Animating
heightfor accordions and drawers. Height changes trigger layout every frame. Where the design allows, slide the panel withtranslateY()and fade it withopacity. If content below really must reflow, accept the cost and keep the duration short. - Animating a shadow. Blur is expensive to paint. Put the shadow on a pseudo-element or wrapper and animate that element's
opacityinstead of thebox-shadowvalue. will-changeon everything. The spec reserves it for elements that are always about to change. Sprinkling it across a stylesheet "just in case" overrides the browser's own layer decisions. Measure first.- Forgetting to remove
will-change. When you add it from JavaScript, remove it after the animation ends. - Using
setTimeout()orsetInterval()for JS animation. They need a fixed delay and don't line up with repaints.requestAnimationFrame()runs before the next repaint and is much more efficient. - Trusting the CSS instead of the profiler. Two animations can look identical in code and differ by half their frames. Record with the Performance panel and check the Rendering value and FPS meter before and after a change.
- Assuming a hidden tab keeps animating. Both CSS animations and rAF pause in background tabs, so don't rely on wall-clock timing for an animation to finish.
Quick reference#
- Animate
transformandopacity. Treat every other property as a cost you have to justify. - Moving:
translate(). Resizing:scale(). Rotating:rotate(). Showing and hiding:opacity. - Keep
positionoffsets static and layertransformon top of them. - Don't animate anything with blur, including
box-shadow. - Transitions and animations perform the same. Choose by the motion you need.
- Prefer CSS over
requestAnimationFrame()so the browser can take the animation off the main thread. Use JS only for complex motion. will-change: transformonly on elements that are always about to change. Otherwise add it from JS before the change and remove it after.transform: translateZ(0)is the fallback.- Verify in Chrome: Performance panel Summary (Rendering should be zero), then the Rendering tab's FPS meter and Paint Flashing.
- Verify in Firefox: Performance Waterfall (no Recalculate Style entries) and the Toggle paint flashing toolbox button.
Quick answers
Is CSS or JavaScript animation faster?
On the main thread they're about equal, because CSS animations and requestAnimationFrame() both resample styles before each repaint. CSS pulls ahead when you animate only transform and opacity on a layered element, since the browser can then run the animation off the main thread. Prefer CSS and reach for JS only for complex motion.
Do CSS transitions perform better than CSS animations?
No. MDN states there's no performance difference between the two. A transition goes from the current style to an end state, while an animation runs keyframes with intermediate steps, so pick by the motion you need.
Is SVG animation slower than CSS animation?
It's the same question in a different wrapper: what matters is which properties change per frame. Animating an SVG element's transform or opacity with CSS follows the same cheap path as any other element. Anything that forces repainting of the graphic is more expensive, so check it with Paint Flashing rather than assuming.
What about canvas vs CSS animation performance?
A canvas animation is a JavaScript animation: a requestAnimationFrame() loop redraws the canvas every frame on the main thread. For moving, scaling, rotating or fading a handful of DOM elements, CSS transform and opacity are the better fit. Canvas earns its place when you're drawing many objects that aren't DOM elements at all.
Are animate.css effects good for performance?
Libraries like animate.css ship ready-made @keyframes, so the performance question is the same as for your own CSS: which properties do the keyframes animate? Effects built on transform and opacity are cheap, while ones that change layout or paint properties will cost the same as if you'd written them yourself.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note