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.
The non-composited-animations audit flags animations that can't run on the GPU compositor. It reports:
width, height, top, left, margin, padding that require layout calculations--custom-prop) can't be compositedWhen you see "Avoid non-composited animations" in Lighthouse, your animations are causing the browser to recalculate layout repeatedly.
The browser renders in layers:
transform and opacityWhen you animate layout properties, every frame requires:
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.
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.
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);
}
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); }
}
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;
}
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 */
}
Hint to the browser that an element will animate:
.frequently-animated {
will-change: transform, opacity;
}
Rules for will-change:
will-change: all — it's counterproductive// Apply before animation
element.style.willChange = 'transform'
element.addEventListener('transitionend', () => {
element.style.willChange = 'auto'
}, { once: true })
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.
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;
}
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'
}
}
}
}
After fixing:
Expected improvement: 0.01-0.1 CLS reduction. More importantly, smoother 60fps animations.
Safe to animate (GPU-accelerated):
transform (translate, rotate, scale, skew)opacityfilter (with caveats)Never animate:
width, heighttop, right, bottom, leftmargin, paddingborder-widthfont-sizeExpensive but sometimes necessary:
background-color (triggers paint, not layout)box-shadow (triggers paint)clip-path (compositor in some browsers)--my-var can't be compositedleft on fixed elements — Even fixed elements should use transformAnimation issues affect different templates differently. Unlighthouse scans your entire site and identifies pages with non-composited animations.