CLS INP Animation

How to Fix CLS Caused by Animations and Transitions

Animations are one of the most common and least obvious sources of Cumulative Layout Shift. When an animation changes a layout-triggering property -- width, height, top, left, margin, or padding -- the browser must recalculate the geometry of every affected element on every frame. On a 60 Hz display, that is 60 layout recalculations per second. Each one can shift surrounding content, contributing to your CLS score and, when the main thread is occupied, to your INP score as well.

The fix is not to remove animations. Animations improve perceived performance and guide user attention when used correctly. The fix is to use only composited properties -- transform and opacity -- which run entirely on the GPU compositor thread and never touch the layout engine. This guide covers how to identify the problem, replace the offending properties, and use modern browser APIs that make layout-safe animations easier than ever.

TL;DR
  • Animate only transform and opacity -- these run on the compositor thread and never cause layout shifts.
  • Replace left/top position animations with transform: translate() and size animations with transform: scale().
  • Use will-change: transform sparingly -- only on frequently animated elements, and only while animating.
  • Reach for the CSS View Transitions API for page and component transitions; it produces zero CLS by design.
  • Wrap all animation code in a prefers-reduced-motion media query to skip or simplify animations for users who need it.

Why animations cause CLS

The browser rendering pipeline has three main phases: layout, paint, and composite. Layout calculates the size and position of every element. Paint converts those boxes to pixels on layers. Composite assembles the layers using the GPU. Changing any property that affects an element's size or position forces the pipeline to restart from the layout phase -- this is called a reflow, and it is expensive.

When an animation runs at 60 fps and each frame triggers a reflow, the browser is performing 60 layout recalculations per second. Each recalculation may cascade: moving one element can force its siblings, parent, and parent's siblings to recalculate their positions too. The layout engine annotates the document with a dirty bit whenever geometry changes, and the next paint must resolve all dirty nodes. This is the mechanism by which animated elements produce Cumulative Layout Shift -- the browser flags the visual shift of every node that moved unexpectedly.

There is one important nuance for CLS measurement: the 500-millisecond user-interaction exclusion window. The CLS specification excludes layout shifts that occur within 500ms of a discrete user interaction (a click, a tap, or a key press). This means a slide-in animation triggered by a button click will not contribute to CLS if it completes within 500ms of that interaction. However, animations triggered autonomously -- entrance animations, auto-playing carousels, sticky headers that shrink on scroll -- are always attributable and always scored. Most real-world animation CLS problems come from these autonomous animations, not interaction-triggered ones.

When layout-triggering animations also execute JavaScript on the main thread, they compound the problem by blocking input processing, which raises INP. A JavaScript-driven animation that calls element.style.left = x + 'px' on every RAF tick both triggers layout and occupies the main thread, creating a scenario where user interactions are queued behind the animation loop.

Composited-only animations: transform and opacity

The browser can promote any element to its own GPU layer and manipulate that layer independently of the main thread layout engine. Two CSS properties trigger this promotion automatically when animated: transform and opacity. When you animate only these properties, the compositor thread handles every frame without involving the main thread at all.

This matters enormously for INP. When the compositor runs animations independently, the main thread remains free to process input events. A button click that arrives during a composited animation is handled without any queuing delay. The same click during a layout-triggering animation may wait until the current layout recalculation completes -- potentially tens of milliseconds.

The will-change CSS property explicitly requests layer promotion before an animation begins. Without it, the browser promotes an element lazily on the first animated frame, which can cause a visible jank on the first frame as the layer is created. Adding will-change: transform instructs the browser to pre-allocate the GPU layer.

GPU memory cost: Every promoted layer consumes GPU texture memory proportional to its pixel area. A full-viewport hero section promoted with will-change: transform on a 4K display can consume over 50 MB of GPU memory. Apply will-change dynamically via JavaScript just before an animation begins and remove it immediately after -- never set it statically in CSS on large or static elements.
CSS -- Composited animation pattern
/* Good: compositor-only, no layout shift */
.card {
  transition: transform 200ms ease, opacity 200ms ease;
}

.card:hover {
  transform: translateY(-4px);
  opacity: 0.9;
}

/* Bad: triggers layout on every frame */
.card {
  transition: top 200ms ease, margin-top 200ms ease;
}

.card:hover {
  top: -4px;        /* triggers layout */
  margin-top: -4px; /* triggers layout */
}
JavaScript -- Dynamic will-change management
const card = document.querySelector('.card');

// Apply will-change just before animation
card.addEventListener('mouseenter', () => {
  card.style.willChange = 'transform';
});

// Remove will-change as soon as the transition ends
card.addEventListener('transitionend', () => {
  card.style.willChange = 'auto';
});

// For programmatic animations with WAAPI
card.addEventListener('click', () => {
  card.style.willChange = 'transform';

  const anim = card.animate(
    [
      { transform: 'scale(1)' },
      { transform: 'scale(1.05)' },
      { transform: 'scale(1)' },
    ],
    { duration: 300, easing: 'ease-in-out', fill: 'none' }
  );

  anim.finished.then(() => {
    card.style.willChange = 'auto';
  });
});

Layout-triggering properties and their safe replacements

The following CSS properties trigger a full layout recalculation when animated. They are among the most common causes of animation-induced CLS and should never appear in CSS animation or transition declarations on visible elements.

Avoid animating Use instead Pipeline cost
left, right, top, bottom transform: translate(x, y) Layout + Paint
width, height transform: scale(x, y) Layout + Paint
margin, padding transform: translate() or gap (static) Layout + Paint
font-size transform: scale() Layout + Paint
background-color, color, box-shadow opacity on overlay layer; or accept paint cost Paint only (no layout)

Note that background-color and box-shadow transitions trigger paint but not layout. They do not cause CLS, but they do cause paint work on the main thread and can contribute to dropped frames. When smoothness is critical, use an opacity transition on a pseudo-element overlay instead.

CSS -- Converting a position animation to transform
/* Before: position-based slide-in (triggers layout every frame) */
@keyframes slide-in-bad {
  from { left: -100px; }
  to   { left: 0px; }
}

.panel {
  position: relative;
  animation: slide-in-bad 300ms ease;
}

/* After: transform-based slide-in (compositor only, zero CLS) */
@keyframes slide-in-good {
  from { transform: translateX(-100px); opacity: 0; }
  to   { transform: translateX(0);      opacity: 1; }
}

.panel {
  animation: slide-in-good 300ms ease;
}

/* Bonus: use @starting-style for entry animations in modern browsers */
@starting-style {
  .panel {
    transform: translateX(-100px);
    opacity: 0;
  }
}

.panel {
  transition: transform 300ms ease, opacity 300ms ease;
  /* Transitions automatically on first paint */
}

CSS View Transitions API and scroll-driven animations

The View Transitions API (Chrome 111+, Safari 18+, Firefox 130+) provides browser-native animated transitions between DOM states. When you wrap a DOM update in document.startViewTransition(), the browser captures a screenshot of the current state, applies the DOM mutation, captures a screenshot of the new state, and composites an animated crossfade between them entirely on the GPU. The DOM update that causes the layout change is instantaneous, masked by the transition animation -- and critically, it produces zero CLS by design.

For same-document transitions (SPAs, tab switches, accordion expands), you can assign view-transition-name to specific elements so they animate as "shared elements" between states rather than simply crossfading. The browser handles the interpolation automatically, avoiding the need to calculate intermediate states in JavaScript.

JavaScript -- View Transitions API basic usage
// Basic same-document transition
async function navigateToTab(tabId) {
  // Check for browser support
  if (!document.startViewTransition) {
    // Fallback: instant update, no animation
    updateTabContent(tabId);
    return;
  }

  // The DOM update runs inside the callback
  // CLS from DOM changes is fully masked by the transition
  const transition = document.startViewTransition(() => {
    updateTabContent(tabId);
  });

  // Optionally await completion
  await transition.finished;
}

// Shared element transition: card -> detail view
function openCard(cardEl) {
  // Give the card a persistent view-transition-name
  cardEl.style.viewTransitionName = 'selected-card';

  document.startViewTransition(() => {
    // Swap to detail view
    cardEl.classList.add('detail-expanded');
    // The browser morphs the card element to its new size/position
    // without causing any layout shift attribution
  });
}
CSS -- Customizing View Transition animations
/* Default crossfade -- override with custom keyframes */
::view-transition-old(root) {
  animation: 200ms ease both fade-out;
}

::view-transition-new(root) {
  animation: 300ms ease both slide-in;
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes slide-in {
  from { transform: translateY(16px); opacity: 0; }
}

/* Named shared element: morph a card thumbnail */
.card-thumbnail {
  view-transition-name: card-thumbnail;
}

::view-transition-group(card-thumbnail) {
  animation-duration: 350ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

/* Respect reduced motion for View Transitions */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.01ms;
  }
}

Scroll-driven animations (Chrome 115+) let you tie animation progress to scroll position using animation-timeline: scroll() or animation-timeline: view(). Because the browser handles timeline interpolation in the compositor, scroll-driven animations that use only transform and opacity do not touch the layout engine. Animating layout properties scroll-driven still causes reflows, so the same rule applies: stick to composited properties. Scroll-driven animations are evaluated entirely off the main thread and cannot cause CLS when used correctly.

Framer Motion, GSAP, Lottie, and other animation libraries

Popular animation libraries take different approaches to the rendering pipeline, and understanding which patterns are CLS-safe will save hours of debugging in production.

Framer Motion (React) animates via the Web Animations API or direct style application. Its motion components default to transform-based animations: the x, y, scale, and rotate props all map to transform under the hood. The layoutId prop is Framer Motion's equivalent of View Transitions shared elements -- it automatically calculates the transform needed to morph an element between two positions and sizes, without ever animating top, left, width, or height. Use layoutId for any animation where content moves or resizes; avoid the animate={{ width: ... }} pattern. See the CLS in React fix guide for React-specific patterns.

TypeScript -- Framer Motion CLS-safe patterns
import { motion, AnimatePresence } from 'framer-motion';

// Good: x/y props map to transform (no layout shift)
function SlideInCard() {
  return (
    <motion.div
      initial={{ x: -40, opacity: 0 }}
      animate={{ x: 0, opacity: 1 }}
      transition={{ duration: 0.3, ease: 'easeOut' }}
    >
      Content
    </motion.div>
  );
}

// Good: layoutId for shared element transitions (zero CLS)
function CardList({ selectedId, cards }) {
  return (
    <>
      {cards.map(card => (
        <motion.div key={card.id} layoutId={`card-${card.id}`}>
          {card.title}
        </motion.div>
      ))}
      {selectedId && (
        <motion.div layoutId={`card-${selectedId}`} className="detail">
          {/* Morphs from list item to detail view */}
        </motion.div>
      )}
    </>
  );
}

// Bad: animating width directly causes layout shift
function BadResize() {
  return (
    <motion.div
      animate={{ width: '100%' }} // triggers layout on every frame
    >
      Avoid this pattern
    </motion.div>
  );
}

GSAP applies styles via JavaScript on every frame using requestAnimationFrame. By default its x, y, rotation, and scale shorthand props map to transform, and GSAP batches all style mutations into a single invalidation per frame to minimize layout thrashing. However, gsap.to(el, { width: '...' }) still triggers layout. Use GSAP's transform shorthands and call gsap.set(el, { willChange: 'transform' }) before long animation sequences, then gsap.set(el, { willChange: 'auto' }) at completion via onComplete.

Lottie renders vector animations exported from After Effects. Lottie's web renderer can render to Canvas (no DOM, no CLS) or SVG (DOM manipulation, potential CLS depending on the animation). For production use, prefer the Canvas renderer (renderer: 'canvas') or the Lottie Web player with Canvas. Avoid the SVG renderer on animations that resize or reposition DOM elements. The CLS in Next.js guide covers Lottie integration patterns for Next.js projects.

Reducing motion for accessibility and performance

The prefers-reduced-motion media query reports whether the user has enabled "Reduce Motion" in their operating system. This setting is used by people with vestibular disorders, motion sensitivity, epilepsy, or attention-related disabilities for whom moving content causes discomfort or impaired reading. It is also a useful signal for performance: devices that report reduced-motion preference are often low-power or battery-constrained.

The correct pattern is progressive enhancement: design animations as an enhancement layer on top of a functional, non-animated baseline. Use the prefers-reduced-motion: reduce media query to either disable animations entirely or replace them with instant transitions. Never hide content that is only revealed by an animation -- the non-animated state must remain accessible.

CSS -- prefers-reduced-motion pattern
/* Base: animation enabled */
.notification {
  transform: translateY(-8px);
  opacity: 0;
  transition: transform 300ms ease, opacity 300ms ease;
}

.notification.is-visible {
  transform: translateY(0);
  opacity: 1;
}

/* Reduced motion: instant appearance, no movement */
@media (prefers-reduced-motion: reduce) {
  .notification {
    transition: opacity 100ms ease;
    transform: none; /* Remove movement entirely */
  }
}

/* Global reset (use with care -- may over-suppress) */
@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;
  }
}
JavaScript -- Runtime reduced-motion detection
// Check preference at runtime
const prefersReducedMotion =
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// Use in animation libraries
function animateElement(el) {
  if (prefersReducedMotion) {
    // Skip animation, apply final state immediately
    el.style.opacity = '1';
    el.style.transform = 'none';
    return;
  }

  el.animate(
    [
      { transform: 'translateY(-8px)', opacity: 0 },
      { transform: 'translateY(0)',    opacity: 1 },
    ],
    { duration: 300, easing: 'ease-out', fill: 'forwards' }
  );
}

// React hook pattern
function useReducedMotion() {
  const [reduced, setReduced] = React.useState(
    () => window.matchMedia('(prefers-reduced-motion: reduce)').matches
  );

  React.useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    const handler = (e) => setReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return reduced;
}

For Framer Motion specifically, pass useReducedMotion() from Framer's hooks to conditionally replace spring/tween variants with instant variants. GSAP's gsap.globalTimeline.timeScale(0) can be used to pause all animations when reduced motion is detected. The Chrome DevTools Performance tutorial covers how to emulate reduced-motion preference in DevTools for testing without changing OS settings.

Step-by-step fix

Step 1: Identify animation-induced layout shifts with Chrome DevTools Layers panel

Open Chrome DevTools and navigate to More Tools > Layers. Trigger your animations. The Layers panel shows every composited layer and highlights elements that cause layer promotion changes. For pinpointing shifts, record a Performance trace and inspect the Layout Shifts track -- each shift event links to the DOM node that moved and the property change that caused it.

Enable the Layout Shifts track: In the Performance panel, click the gear icon and enable "Layout Shift Regions". Shifts appear as blue-outlined rectangles in the page view during recording. The Layout Shifts row in the timeline shows shift events with their CLS score contribution.
JavaScript -- PerformanceObserver for shift attribution
// Log layout shifts with source element attribution
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue; // Skip interaction-attributed shifts

    console.group(`Layout shift: ${entry.value.toFixed(4)}`);
    for (const source of entry.sources) {
      console.log('Element:', source.node);
      console.log(
        'Before:',
        `${source.previousRect.x},${source.previousRect.y}`,
        `${source.previousRect.width}x${source.previousRect.height}`
      );
      console.log(
        'After:',
        `${source.currentRect.x},${source.currentRect.y}`,
        `${source.currentRect.width}x${source.currentRect.height}`
      );
    }
    console.groupEnd();
  }
});

observer.observe({ type: 'layout-shift', buffered: true });

Step 2: Replace layout-triggering animations with transform and opacity

For each shift-causing animation identified in Step 1, apply the property substitutions from the table above. The most common replacements are: top/left to translateX/translateY, width/height resize effects to scale(), and explicit position changes to translate() with fixed layout dimensions.

CSS -- Before and after: notification slide-down
/* Before: animates height and margin (two layout-triggering properties) */
@keyframes expand-bad {
  from { height: 0; margin-bottom: 0; overflow: hidden; }
  to   { height: 60px; margin-bottom: var(--space-4); }
}

/* After: translate into view from above (compositor-only) */
@keyframes slide-down-good {
  from {
    transform: translateY(-100%);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

.notification {
  /* Reserve space in layout statically -- never animate it */
  height: 60px;
  margin-bottom: var(--space-4);
  animation: slide-down-good 250ms ease forwards;
}

Step 3: Add will-change hints carefully to promote to composited layer

After switching to composited properties, add will-change: transform to elements that animate repeatedly (carousels, auto-playing banners, scroll-linked parallax elements). Apply it dynamically rather than in static CSS to avoid holding GPU memory between animations.

JavaScript -- Carousel with dynamic will-change
class Carousel {
  constructor(el) {
    this.el = el;
    this.track = el.querySelector('.carousel-track');
  }

  slideTo(index) {
    // Promote layer just before animation
    this.track.style.willChange = 'transform';

    const offset = index * -100;
    this.track.style.transform = `translateX(${offset}%)`;

    // Remove promotion after transition completes
    this.track.addEventListener(
      'transitionend',
      () => {
        this.track.style.willChange = 'auto';
      },
      { once: true }
    );
  }
}

// CSS for the carousel track
/*
.carousel-track {
  display: flex;
  transition: transform 400ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
*/

Step 4: Use the CSS View Transitions API for navigation animations

For any animation that involves content entering, exiting, or morphing between states -- tab switches, modal open/close, page navigation in SPAs -- replace custom JavaScript animations with the View Transitions API. The API handles the visual transition on the GPU, producing zero CLS regardless of what DOM changes occur inside the callback.

JavaScript -- SPA navigation with View Transitions fallback
// Router integration with View Transitions
async function navigate(url) {
  if (!document.startViewTransition) {
    // Graceful fallback for unsupported browsers
    await fetchAndRenderPage(url);
    return;
  }

  const transition = document.startViewTransition(async () => {
    // All DOM mutations inside here are zero-CLS
    await fetchAndRenderPage(url);
  });

  // Handle skip for reduced motion
  const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (prefersReduced) {
    await transition.skipTransition();
  }

  try {
    await transition.finished;
  } catch {
    // Transition interrupted -- not an error
  }
}

// Intercept link clicks
document.addEventListener('click', (e) => {
  const link = e.target.closest('a[href]');
  if (!link || link.origin !== location.origin) return;

  e.preventDefault();
  navigate(link.href);
});

Step 5: Validate with the Performance panel and RUM

After applying fixes, record a new Performance trace in Chrome DevTools and verify the Layout Shifts track shows no shift events during animations. Check the Frames timeline for dropped frames (shown in red) which indicate that even composited animations may be producing paint work that takes longer than 16ms. Validate in production using Real User Monitoring to confirm CLS improvements in field data.

JavaScript -- RUM CLS tracking with attribution
import { onCLS } from 'web-vitals/attribution';

onCLS((metric) => {
  const { largestShiftSource, largestShiftValue } = metric.attribution;

  // Send to your analytics endpoint
  navigator.sendBeacon('/analytics', JSON.stringify({
    name: 'CLS',
    value: metric.value,
    rating: metric.rating,         // 'good' | 'needs-improvement' | 'poor'
    largestShiftValue,
    largestShiftSelector: largestShiftSource?.node
      ? getSelector(largestShiftSource.node)
      : null,
    url: location.href,
    navigationType: metric.navigationType,
  }));
});

// Build a CSS selector string from a DOM node
function getSelector(node) {
  if (!node || node.nodeType !== Node.ELEMENT_NODE) return '';
  const tag = node.tagName.toLowerCase();
  const id = node.id ? `#${node.id}` : '';
  const cls = Array.from(node.classList).slice(0, 2).map(c => `.${c}`).join('');
  return `${tag}${id}${cls}`;
}

Common mistakes

  • Applying will-change in static CSS to large elements. Setting will-change: transform permanently on a hero section or background image holds GPU texture memory for that layer indefinitely. Reserve it for elements that animate repeatedly and remove it after each animation cycle.
  • Mixing transform and layout properties in a single animation. Animating transform: translateX() while simultaneously animating width in the same keyframe sequence still triggers layout recalculation. All properties in an animation must be composited for the animation to stay off the main thread.
  • Forgetting that scale() changes the visual size without affecting layout. transform: scale(2) makes an element appear twice as large but does not affect the layout box -- neighboring elements do not move. This is exactly what you want for visual animations, but if you expect the scale to reflow siblings, you need to change actual dimensions instead.
  • Relying on the 500ms interaction exclusion window as a safety net. The interaction exclusion only applies to discrete user interactions (click, tap, key press). Animations triggered by scroll events, timers, or API responses are never excluded and always contribute to the CLS score, even if they feel like responses to user action.
  • Not testing reduced-motion behavior. DevTools can emulate prefers-reduced-motion: reduce under Rendering settings. Test that all animations are either removed or replaced with instant state changes, and that no content is hidden or unavailable in the reduced-motion state.

Frequently asked questions

Layout (reflow) is the most expensive phase -- the browser recalculates the size and position of every affected element in the DOM tree. Paint converts layout boxes to pixels on layers. Composite assembles those layers and draws them to the screen using the GPU. Only transform and opacity changes are handled entirely in the composite phase, bypassing layout and paint entirely. Any property that triggers layout (width, height, top, left, margin) forces the browser to recalculate the geometry of potentially thousands of nodes, which is both CPU-intensive and the direct cause of layout shifts.

will-change tells the browser to promote an element to its own composited layer ahead of time, consuming GPU texture memory for that layer continuously -- not just during animation. On memory-constrained devices like mobile phones, over-using will-change can cause the browser to evict other layers, causing jank from layer re-promotion. Applying will-change to many elements simultaneously can exhaust GPU memory entirely. The best practice is to add will-change dynamically via JavaScript just before an animation starts and remove it immediately after by setting will-change: auto.

Neither technology is inherently faster -- what matters is which CSS properties you animate, not whether you use CSS or JavaScript. A JavaScript animation using transform and opacity via the Web Animations API (element.animate()) runs on the compositor thread and is just as fast as a CSS keyframe animation. A CSS animation that transitions width or height will still cause layout recalculations. However, CSS animations have one meaningful advantage: they can run on the compositor thread even when the main thread is busy processing JavaScript, which improves smoothness during heavy INP scenarios where user interaction handlers are executing.

No. The View Transitions API captures a screenshot of the old state and a screenshot of the new state, then composites an animated transition between them entirely on the GPU. The DOM update that changes layout happens instantly inside document.startViewTransition(), but because the visual change is masked by the transition animation, the browser does not attribute any layout shift score to it. This makes View Transitions one of the safest ways to animate between navigation states without any CLS impact.

The cleanest pure-CSS approach is to animate grid-template-rows from 0fr to 1fr with overflow: hidden on the container. This is supported in Chrome 107+, Firefox 109+, and Safari 16.5+ and produces a smooth height transition with a single layout event per state change rather than continuous reflows. For older browser support, animate max-height from 0 to a known maximum combined with overflow: hidden. The CSS View Transitions API is the most robust solution for enter/exit animations on dynamically inserted elements -- the DOM update is instantaneous and the visual animation runs on the compositor.

Marcus Chen

Senior Performance Engineer. Specializes in rendering performance, Core Web Vitals optimization, and animation pipelines in React and native browser APIs. Previously at a major e-commerce platform where animation-induced CLS caused measurable checkout drop-off.

Related fixes