A well-timed animation makes an interface feel polished: a menu that slides in, a card that lifts on hover, a button that confirms a tap. A badly built animation does the opposite – it stutters, drains battery and makes the whole page feel slow, especially on budget phones. The difference is rarely the design itself. It is which CSS properties you animate.
This post explains why CSS animation performance depends so much on that choice, with practical rules you can apply today. If you need a refresher on syntax, see the CSS Transitions and Animations lesson.
How the browser draws a frame
To feel smooth, an animation needs a new frame roughly every 16 milliseconds on a 60Hz screen (more often on 90Hz or 120Hz displays). For each frame, the browser may have to run through these steps:
- Style – work out which CSS rules apply to each element.
- Layout – calculate the size and position of every affected box.
- Paint – fill in the pixels: text, colours, borders, shadows.
- Composite – combine the painted layers in the right order on screen.
The later in this pipeline your change starts, the less work the browser does. Changing width triggers layout, then paint, then composite – for the element and possibly everything around it. Changing transform or opacity can often skip layout and paint entirely and be handled at the composite step, frequently on the GPU.
Rule 1: animate transform and opacity
| Instead of animating… | Animate this | Why |
|---|---|---|
left, top, margin | transform: translate() | No layout recalculation |
width, height | transform: scale() | No layout; neighbours do not move |
visibility or display for fades | opacity | Can be composited |
box-shadow size | opacity of a pseudo-element that holds the larger shadow | Shadows are expensive to repaint |
background-position for slides | transform on an inner element | Avoids repainting the background |
Here is the shadow trick, which is very common for hover effects on cards:
.card {
position: relative;
transition: transform 200ms ease-out;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 24px rgb(0 0 0 / 0.18);
opacity: 0;
transition: opacity 200ms ease-out;
pointer-events: none;
}
.card:hover { transform: translateY(-4px); }
.card:hover::after { opacity: 1; }See the difference yourself
The demo moves two boxes back and forth. One animates left, the other animates transform. On a fast laptop both may look fine. Turn on CPU throttling in DevTools (Performance panel → CPU: 4× or 6× slowdown) and press “Make the page busy” to simulate a struggling phone – the left version usually suffers first.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Animation performance demo</title>
<style>
body { font-family: system-ui, sans-serif; margin: 16px; }
.track { position: relative; height: 56px; background: #f0f2f7; border-radius: 8px; margin: 10px 0; overflow: hidden; }
.box { position: absolute; top: 8px; width: 40px; height: 40px; border-radius: 6px; }
.layout { background: #d9534f; left: 8px; animation: moveLeft 1.6s ease-in-out infinite alternate; }
.gpu { background: #2f7d4f; left: 8px; animation: moveTransform 1.6s ease-in-out infinite alternate; }
@keyframes moveLeft { to { left: calc(100% - 48px); } }
@keyframes moveTransform { to { transform: translateX(calc(100vw - 88px)); } }
button { font: inherit; padding: 8px 12px; }
@media (prefers-reduced-motion: reduce) {
.box { animation-duration: 6s; }
}
</style>
</head>
<body>
<p>Red: animates <code>left</code>. Green: animates <code>transform</code>.</p>
<div class="track"><div class="box layout"></div></div>
<div class="track"><div class="box gpu"></div></div>
<button id="busy">Make the page busy for 3 seconds</button>
<p id="status"></p>
<script>
document.getElementById('busy').addEventListener('click', function () {
var status = document.getElementById('status');
var end = performance.now() + 3000;
status.textContent = 'Busy...';
function chunk() {
var stop = performance.now() + 40;
while (performance.now() < stop) { Math.sqrt(Math.random()); }
if (performance.now() < end) { setTimeout(chunk, 0); }
else { status.textContent = 'Done.'; }
}
chunk();
});
</script>
</body>
</html>Tip: Results vary by browser and device; modern browsers are clever. The point of the demo is the habit: reach for transform and opacity first, then measure.
Rule 2: use will-change sparingly
will-change: transform tells the browser an element is about to animate, so it can prepare a separate layer in advance. That can remove a stutter at the start of an animation. But every layer uses memory. Adding will-change to dozens of elements, or to *, can make performance worse – particularly on low-memory phones.
- Use it only on elements that actually animate, and only if you have measured a problem.
- Prefer adding it shortly before the animation (for example on hover of the parent) rather than permanently.
- Never use it as a general “make things faster” switch.
Rule 3: avoid layout thrashing in JavaScript
If you animate with JavaScript, the classic mistake is reading a layout value (like offsetHeight) and then writing a style, again and again in a loop. Each read after a write forces the browser to recalculate layout immediately. Batch your reads first, then your writes, and use requestAnimationFrame to schedule visual updates.
const cards = document.querySelectorAll('.card');
requestAnimationFrame(() => {
// 1. read everything
const heights = Array.from(cards, (c) => c.offsetHeight);
// 2. then write everything
cards.forEach((c, i) => {
c.style.setProperty('--h', heights[i] + 'px');
});
});Better still, let CSS do the motion: JavaScript toggles a class, and a CSS transition animates transform. The JavaScript DOM Basics for Designers post shows this pattern.
Rule 4: keep durations short and purposeful
Performance is also about perceived speed. Long animations make an interface feel slow even when the frame rate is perfect. Typical ranges that feel responsive:
| Interaction | Suggested duration |
|---|---|
| Button press, hover, toggle | 100–200 ms |
| Dropdown, tooltip, small panel | 150–250 ms |
| Modal, drawer, page-level transition | 200–400 ms |
| Decorative loop (loader) | As needed, but stop when content is ready |
Use ease-out for things entering the screen (fast start, gentle stop) and ease-in for things leaving. Interactions that need instant feedback also help your INP score – see Core Web Vitals for Designers.
Rule 5: respect prefers-reduced-motion
Some people experience dizziness or nausea from motion on screen, and many operating systems offer a “reduce motion” setting. Honour it. You do not have to remove every transition – a simple fade is usually fine – but large movements, parallax and zooming effects should be reduced or switched off.
@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;
}
}This blanket rule is a useful safety net. For important motion that explains something (for example a progress indicator), provide a gentler alternative instead of removing it. This also supports WCAG guidance on animation from interactions – read more in Web Accessibility Basics.
What about colours, shadows and filters?
Not everything has to be transform or opacity. Changing color or background-color on hover triggers paint but not layout. For a single button over 150 ms, that cost is tiny and perfectly fine. The problems start when paint-heavy properties change on large areas, on many elements at once, or on every frame of a long or infinite animation.
- Fine: a colour change on a button hover, a border colour on input focus.
- Be careful: animating
box-shadowon a grid of 30 cards, or a gradient background that moves forever behind the hero. - Avoid on mobile: animating
filter: blur()orbackdrop-filterover large areas. These are among the most expensive effects to repaint.
Stop animations nobody can see
Infinite animations keep costing work even when they are scrolled out of view or covered by another element. Loaders should stop as soon as content arrives. For decorative loops, pause them when they leave the viewport by toggling animation-play-state: paused from an IntersectionObserver callback, or simply let them run a few times and end. Your users’ batteries will thank you.
Rule 6: measure CSS animation performance, do not guess
- Open Chrome DevTools → Performance panel and record while the animation runs.
- Look for long purple (rendering/layout) and green (painting) bars during the animation.
- Enable “Paint flashing” in the Rendering panel; areas repainted every frame flash green.
- Repeat with CPU throttling to approximate a mid-range Android phone.
- Fix the biggest problem first – usually a layout property being animated.
CSS animation performance checklist
- Animate
transformandopacity; avoid animating layout properties. - Do not animate large
box-shadow,filter: blur()or big gradients on every frame. - Pause off-screen or infinite animations when they are not visible.
- Keep UI transitions under about 300 ms.
- Support
prefers-reduced-motion. - Test on a real low-end device or with CPU throttling.
Frequently asked questions
Which CSS properties are cheapest to animate?
transform and opacity, because browsers can usually animate them in the compositing step without recalculating layout or repainting.
Are CSS animations faster than JavaScript animations?
Not automatically. What matters most is which properties change. CSS transitions and the Web Animations API on transform and opacity are both efficient; JavaScript that changes layout properties every frame is not.
Should I add will-change to everything?
No. Each promoted layer uses memory. Apply will-change only to elements that animate and only when you have measured a benefit.
What does prefers-reduced-motion do?
It is a media feature that matches when the user has asked their operating system to minimise motion. Use it to reduce or remove large animations.
Can I animate height for an accordion smoothly?
Animating height triggers layout, which is acceptable for small, short, occasional animations. For large or frequent ones, consider animating transform or opacity of the content instead, or keep the open state instant.
Further reading
- web.dev/articles/animations-guide
- web.dev/articles/stick-to-compositor-only-properties-and-manage-layer-count
- developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/will-change
- developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion
- w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html


