---
title: "Fix Animations & Transitions for Better CLS"
description: "Eliminate CLS from animations by using compositor-friendly properties like transform and opacity instead of layout-triggering properties."
canonical_url: "https://unlighthouse.dev/learn-lighthouse/cls/animations-transitions"
last_updated: "2025-01-18"
---

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.

<audit-impact current-value="0.15" metric="cls" target-value="0.02">



</audit-impact>

## 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`:

```css
/* 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`:

```css
/* 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:

```css
.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:

```css
/* 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:

```css
/* 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:

```css
/* 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**

```css
.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**

```css
.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**

```css
.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:

```css
.frequently-animated {
  will-change: transform, opacity;
}
```

**Rules for will-change:**

- Only apply to elements that 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

```js
// 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:

```css
.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 trigger repaints:

```css
/* 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

<callout icon="i-logos-nextjs-icon">

**Next.js / React**

CSS transitions are fine. For complex animations, use Framer Motion with compositor-friendly properties:

```jsx
import { motion } from 'framer-motion'

// GOOD: only animates transform and opacity
export function AnimatedBox() {
  return (
    <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:

```jsx
// CAREFUL: layout prop causes CLS
<motion.div layout>
  {/* This animates width/height internally */}
</motion.div>
```

</callout>

<callout icon="i-logos-nuxt-icon">

**Nuxt / Vue**

Vue transitions work great when you use transform:

```html
<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):

```html
<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>
```

</callout>

<callout icon="i-logos-tailwindcss-icon">

**Tailwind CSS**

Tailwind's animation utilities use compositor properties by default:

```html
<!-- 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:

```js
// tailwind.config.js
export default {
  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'
      }
    }
  }
}
```

</callout>

## 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

## Related issues

- [Dynamic Content](/learn-lighthouse/cls/dynamic-content-injection) - Animated content appearing
- [Web Fonts](/learn-lighthouse/cls/web-fonts-causing-foit) - 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.
