Fix Animations & Transitions for Better CLS

Eliminate CLS from animations by using compositor-friendly properties like transform and opacity instead of layout-triggering properties.
Harlan WiltonHarlan Wilton5 min read Published

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.

CLSCumulative Layout Shift CWV
25% weight
Good ≤0.10Poor >0.25

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, padding that 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:

  1. Main thread — JavaScript, DOM, layout calculations
  2. Compositor thread — GPU-accelerated rendering of transform and opacity

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

  1. Open DevTools > Performance
  2. Record while triggering your animation
  3. Look for purple "Layout" blocks during the animation
  4. Multiple Layout events = non-composited animation

Rendering Panel

  1. Open DevTools > More tools > Rendering
  2. Enable "Paint flashing" (green = repaints)
  3. Enable "Layout Shift Regions" (blue = shifts)
  4. Trigger your animations
  5. Green flashes everywhere = non-composited

Layers Panel

  1. Open DevTools > More tools > Layers
  2. Check if animated elements have their own layer
  3. 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

Next.js / ReactCSS transitions are fine. For complex animations, use Framer Motion with compositor-friendly properties:
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>
Avoid Framer Motion's 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>
Nuxt / VueVue transitions work great when you use transform:
<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>
For lists, Vue's TransitionGroup uses FLIP (compositor-friendly):
<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>
Tailwind CSSTailwind's animation utilities use compositor properties by default:
<!-- 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 -->
Define custom animations with compositor properties:
// 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:

  1. Performance panel — Record animation, look for absence of purple Layout blocks
  2. Layers panel — Confirm animated elements have dedicated compositor layers
  3. Paint flashing — Minimal or no green flashes during animation
  4. Layout Shift Regions — No blue flashes during animation
  5. 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)
  • opacity
  • filter (with caveats)

Never animate:

  • width, height
  • top, right, bottom, left
  • margin, padding
  • border-width
  • font-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-var can't be composited
  • Using left on 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

Site-Wide Audit

Animation issues affect different templates differently. Unlighthouse scans your entire site and identifies pages with non-composited animations.