Fix Animations & Transitions for Better CLS
39% of mobile pages use non-composited animations that trigger layout recalculations on every frame. These animations cause CLS, jank, and battery drain—all avoidable with the right CSS properties.
What Lighthouse Detects
The non-composited-animations audit flags animations that can't run on the GPU compositor. It reports:
- Unsupported CSS Properties — Properties like
width,height,top,left,margin,paddingthat require layout calculations - Custom CSS Properties — CSS variables (
--custom-prop) can't be composited - Transform depends on box size — Percentage-based transforms that need layout info
- Filter may move pixels — Certain filters that affect layout
- Incompatible animations — Multiple animations on the same element conflicting
When you see "Avoid non-composited animations" in Lighthouse, your animations are causing the browser to recalculate layout repeatedly.
Why Non-Composited Animations Hurt
The browser renders in layers:
- Main thread — JavaScript, DOM, layout calculations
- Compositor thread — GPU-accelerated rendering of
transformandopacity
When you animate layout properties, every frame requires:
- Recalculating element positions (Layout)
- Repainting affected pixels (Paint)
- Compositing layers (Composite)
When you animate compositor properties, only step 3 runs. It's 60fps vs stuttering.
CLS impact: Each layout recalculation during animation can register as a layout shift. A modal sliding in by animating top creates measurable CLS.
How to Diagnose
DevTools Performance Panel
- Open DevTools > Performance
- Record while triggering your animation
- Look for purple "Layout" blocks during the animation
- Multiple Layout events = non-composited animation
Rendering Panel
- Open DevTools > More tools > Rendering
- Enable "Paint flashing" (green = repaints)
- Enable "Layout Shift Regions" (blue = shifts)
- Trigger your animations
- Green flashes everywhere = non-composited
Layers Panel
- Open DevTools > More tools > Layers
- Check if animated elements have their own layer
- No layer = no compositor optimization
The Fixes
1. Replace Position with Transform
The most common mistake—animating top, left, right, bottom:
/* BAD: animates layout property */
.element {
position: relative;
top: 0;
transition: top 0.3s ease;
}
.element:hover {
top: -10px;
}
/* GOOD: animates compositor property */
.element {
transition: transform 0.3s ease;
}
.element:hover {
transform: translateY(-10px);
}
Same visual result. Zero layout shifts.
2. Replace Size with Scale
Don't animate width and height:
/* BAD: animates width/height */
.button {
width: 100px;
height: 40px;
transition: width 0.2s, height 0.2s;
}
.button:hover {
width: 110px;
height: 44px;
}
/* GOOD: animates scale */
.button {
transition: transform 0.2s ease;
}
.button:hover {
transform: scale(1.1);
}
Use transform-origin to control where scaling originates:
.card {
transform-origin: top left; /* Expands from corner */
transition: transform 0.3s ease;
}
.card:hover {
transform: scale(1.05);
}
3. Replace Margin with Transform
Slide-in animations often abuse margins:
/* BAD: animates margin */
@keyframes slideIn {
from { margin-left: -100%; }
to { margin-left: 0; }
}
/* GOOD: animates transform */
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
4. Use Opacity for Show/Hide
Fading elements is compositor-friendly:
/* BAD: display/visibility causes layout */
.modal {
display: none;
}
.modal.active {
display: block;
}
/* GOOD: opacity + pointer-events */
.modal {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.modal.active {
opacity: 1;
pointer-events: auto;
}
5. Handle Accordions Carefully
Height animations are tricky. The "animate height" pattern always causes layout shifts:
/* BAD: max-height animation */
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.accordion-content.open {
max-height: 500px; /* Arbitrary, causes shift */
}
Better alternatives:
Option A: Use CSS Grid
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
Option B: Reserve space, animate opacity
.accordion-content {
height: var(--content-height); /* Set via JS */
opacity: 0;
overflow: hidden;
transition: opacity 0.3s ease;
}
.accordion-content.open {
opacity: 1;
}
Option C: Accept the shift, minimize impact
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
contain: layout; /* Limit shift impact */
}
6. Use will-change Strategically
Hint to the browser that an element will animate:
.frequently-animated {
will-change: transform, opacity;
}
Rules for will-change:
- Only apply to elements that actually animate frequently
- Don't put it on everything (wastes GPU memory)
- Add before animation starts, remove after
- Don't use
will-change: all— it's counterproductive
// Apply before animation
element.style.willChange = 'transform'
element.addEventListener('transitionend', () => {
element.style.willChange = 'auto'
}, { once: true })
7. Use CSS Containment
Tell the browser an element's layout is independent:
.animated-card {
contain: layout; /* Layout changes don't affect siblings */
}
.animated-modal {
contain: strict; /* Fully isolated for rendering */
}
contain: layout prevents the element from causing layout shifts outside its bounds—essential for animated components.
8. Avoid Box-Shadow Animations
Box-shadow can trigger repaints:
/* BAD: expensive box-shadow */
.card {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.3s;
}
.card:hover {
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
}
/* GOOD: pseudo-element technique */
.card {
position: relative;
}
.card::after {
content: '';
position: absolute;
inset: 0;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.card:hover::after {
opacity: 1;
}
Framework Solutions
import { motion } from 'framer-motion'
// GOOD: only animates transform and opacity
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
Content
</motion.div>
layout prop for production—it animates layout properties and causes CLS:// CAREFUL: layout prop causes CLS
<motion.div layout>
{/* This animates width/height internally */}
</motion.div>
<template>
<Transition name="slide-fade">
<div v-if="show">
Content
</div>
</Transition>
</template>
<style>
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</TransitionGroup>
</template>
<style>
.list-move {
transition: transform 0.5s ease;
}
.list-enter-active,
.list-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.9);
}
</style>
<!-- GOOD: these are compositor-friendly -->
<div class="animate-fade-in" /> <!-- opacity -->
<div class="animate-slide-up" /> <!-- transform -->
<div class="hover:scale-105 transition-transform" />
<div class="hover:-translate-y-1 transition-transform" />
<!-- CAREFUL: these might not be -->
<div class="animate-pulse" /> <!-- uses opacity, fine -->
<div class="animate-bounce" /> <!-- uses transform, fine -->
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
'slide-in': {
'0%': { transform: 'translateX(-100%)', opacity: '0' },
'100%': { transform: 'translateX(0)', opacity: '1' }
}
},
animation: {
'slide-in': 'slide-in 0.3s ease-out'
}
}
}
}
Verification
After fixing:
- Performance panel — Record animation, look for absence of purple Layout blocks
- Layers panel — Confirm animated elements have dedicated compositor layers
- Paint flashing — Minimal or no green flashes during animation
- Layout Shift Regions — No blue flashes during animation
- Lighthouse audit — "Avoid non-composited animations" should pass
Expected improvement: 0.01-0.1 CLS reduction. More importantly, smoother 60fps animations.
Compositable Properties Reference
Safe to animate (GPU-accelerated):
transform(translate, rotate, scale, skew)opacityfilter(with caveats)
Never animate:
width,heighttop,right,bottom,leftmargin,paddingborder-widthfont-size
Expensive but sometimes necessary:
background-color(triggers paint, not layout)box-shadow(triggers paint)clip-path(compositor in some browsers)
Common Mistakes
- Animating CSS custom properties —
--my-varcan't be composited - Using
lefton fixed elements — Even fixed elements should use transform - will-change on everything — Causes memory issues, only use on frequently animated elements
- Mixing composited and non-composited — Browser may demote to non-composited
Related Issues
- Dynamic Content — Animated content appearing
- Web Fonts — Font loading triggering animations
Site-Wide Audit
Animation issues affect different templates differently. Unlighthouse scans your entire site and identifies pages with non-composited animations.