Skip to main content

Command Palette

Search for a command to run...

CSS Animations: Keyframes Transitions

Published
10 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Traditional Animation Approaches Fail Modern Performance Requirements

The animation techniques that worked adequately in 2018—animating properties like width, height, top, or left—now create measurable performance degradation. Every frame that animates these properties forces the browser to recalculate layout (reflow), repaint affected areas, and composite layers. On a 120Hz display, this means the browser must complete this expensive pipeline 120 times per second.

Modern web applications face constraints that didn't exist five years ago. Users expect 120fps animations on high-refresh displays. Core Web Vitals directly impact SEO rankings. Third-party scripts compete for main thread time. Background tasks like service workers, analytics, and real-time data synchronization create constant CPU pressure. Animation implementations that don't leverage hardware acceleration and compositor-only properties simply cannot meet these requirements.

The shift toward component-based architectures with React, Vue, and Svelte has also changed how animations integrate with application state. Animations must now coordinate with virtual DOM updates, respect React's concurrent rendering, and avoid blocking state transitions. Traditional jQuery-style animations or CSS transitions applied without understanding the rendering pipeline create race conditions and visual inconsistencies.

Understanding the Rendering Pipeline and Compositor-Only Properties

To implement performant CSS animations, you must understand which properties can be animated on the GPU compositor thread versus those requiring main thread involvement. Only four CSS properties can be animated without triggering layout or paint: transform, opacity, filter, and backdrop-filter.

When you animate transform: translateX(100px), the browser promotes the element to its own compositor layer, and the GPU handles position changes independently of the main thread. This means animations continue smoothly even when JavaScript execution blocks the main thread. Conversely, animating left: 100px forces layout recalculation for the element and potentially its siblings, parents, and children—a cascade that can affect hundreds of DOM nodes.

Here's a production-grade example demonstrating the difference:

/* Anti-pattern: Triggers layout on every frame */
.modal-enter-bad {
  animation: slideInBad 300ms ease-out;
}

@keyframes slideInBad {
  from {
    top: -100%;
  }
  to {
    top: 0;
  }
}

/* Correct: Compositor-only animation */
.modal-enter-good {
  animation: slideInGood 300ms ease-out;
  will-change: transform;
}

@keyframes slideInGood {
  from {
    transform: translateY(-100%);
  }
  to {
    transform: translateY(0);
  }
}

The will-change property hints to the browser that this element will animate, allowing layer promotion before the animation starts. However, overusing will-change creates memory pressure by maintaining excessive compositor layers. Apply it judiciously and remove it after animations complete.

Choosing Between Transitions and Keyframe Animations

CSS transitions and keyframe animations serve different purposes in modern applications. Transitions excel at state-based animations triggered by user interaction or class changes. They're declarative, require minimal code, and integrate naturally with component state management.

.button {
  background-color: #0066cc;
  transform: scale(1);
  transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1),
              background-color 150ms ease-out;
}

.button:hover {
  background-color: #0052a3;
  transform: scale(1.05);
}

.button:active {
  transform: scale(0.98);
}

This transition pattern provides immediate visual feedback with minimal performance cost. The cubic-bezier timing function creates a material design-style easing that feels responsive without being jarring.

Keyframe animations provide precise control over multi-step animations, looping behaviors, and complex timing sequences. Use them for loading indicators, attention-grabbing effects, or orchestrated animation sequences:

.skeleton-loader {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
}

@keyframes shimmer {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

This skeleton loader animates background-position, which triggers paint but not layout. While not compositor-only, it's acceptable for loading states that appear briefly and don't coincide with heavy user interaction.

Coordinating Animations with JavaScript and Component Lifecycle

Modern frameworks require careful coordination between CSS animations and component state. React 18's concurrent rendering can interrupt renders, causing animations to start before DOM updates complete. The Web Animations API provides programmatic control while maintaining performance benefits:

interface AnimationConfig {
  duration: number;
  easing: string;
  fill?: FillMode;
}

class AnimationController {
  private activeAnimations: Map<Element, Animation> = new Map();

  async animateElement(
    element: Element,
    keyframes: Keyframe[],
    config: AnimationConfig
  ): Promise<void> {
    // Cancel any existing animation on this element
    this.cancelAnimation(element);

    const animation = element.animate(keyframes, {
      duration: config.duration,
      easing: config.easing,
      fill: config.fill || 'forwards'
    });

    this.activeAnimations.set(element, animation);

    try {
      await animation.finished;
    } finally {
      this.activeAnimations.delete(element);
    }
  }

  cancelAnimation(element: Element): void {
    const existing = this.activeAnimations.get(element);
    if (existing) {
      existing.cancel();
      this.activeAnimations.delete(element);
    }
  }

  cancelAll(): void {
    this.activeAnimations.forEach(animation => animation.cancel());
    this.activeAnimations.clear();
  }
}

// Usage in a React component
const controller = new AnimationController();

const handleExpand = async (element: HTMLElement) => {
  await controller.animateElement(
    element,
    [
      { transform: 'scaleY(0)', opacity: 0 },
      { transform: 'scaleY(1)', opacity: 1 }
    ],
    {
      duration: 250,
      easing: 'cubic-bezier(0.4, 0, 0.2, 1)'
    }
  );
};

This pattern provides cancellation support, prevents animation conflicts, and integrates cleanly with async component logic. The Web Animations API also enables dynamic timing adjustments based on user preferences or device capabilities.

Respecting User Preferences and Accessibility

The prefers-reduced-motion media query has become mandatory for accessible animation implementation. Users with vestibular disorders, motion sensitivity, or those who simply prefer minimal animation enable this setting. Ignoring it creates accessibility barriers and poor user experience:

.card {
  transition: transform 300ms ease-out;
}

.card:hover {
  transform: translateY(-8px);
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }

  .card:hover {
    transform: none;
    /* Provide alternative feedback */
    box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.3);
  }
}

In TypeScript, check this preference programmatically:

const prefersReducedMotion = (): boolean => {
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
};

const getAnimationDuration = (defaultMs: number): number => {
  return prefersReducedMotion() ? 0 : defaultMs;
};

// Usage
element.style.transitionDuration = `${getAnimationDuration(300)}ms`;

Performance Monitoring and Animation Profiling

Implementing animations correctly requires measurement. Chrome DevTools Performance panel reveals layout thrashing, long paint times, and dropped frames. Record a performance profile during animation execution and examine the flame chart for red triangles indicating forced synchronous layouts.

class AnimationPerformanceMonitor {
  private observer: PerformanceObserver;
  private metrics: Map<string, number[]> = new Map();

  constructor() {
    this.observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'measure') {
          const existing = this.metrics.get(entry.name) || [];
          existing.push(entry.duration);
          this.metrics.set(entry.name, existing);
        }
      }
    });

    this.observer.observe({ entryTypes: ['measure'] });
  }

  startMeasure(name: string): void {
    performance.mark(`${name}-start`);
  }

  endMeasure(name: string): void {
    performance.mark(`${name}-end`);
    performance.measure(name, `${name}-start`, `${name}-end`);
  }

  getAverageDuration(name: string): number {
    const durations = this.metrics.get(name) || [];
    if (durations.length === 0) return 0;
    return durations.reduce((a, b) => a + b, 0) / durations.length;
  }

  getP95Duration(name: string): number {
    const durations = this.metrics.get(name) || [];
    if (durations.length === 0) return 0;
    const sorted = [...durations].sort((a, b) => a - b);
    const index = Math.floor(sorted.length * 0.95);
    return sorted[index];
  }
}

// Usage
const monitor = new AnimationPerformanceMonitor();

monitor.startMeasure('modal-animation');
await animateModal();
monitor.endMeasure('modal-animation');

console.log('Average duration:', monitor.getAverageDuration('modal-animation'));
console.log('P95 duration:', monitor.getP95Duration('modal-animation'));

Target animation durations under 16ms for 60fps or 8ms for 120fps displays. If measurements consistently exceed these thresholds, the animation triggers layout or paint operations.

Common Pitfalls and Edge Cases

Layer Explosion: Overusing will-change or creating too many animated elements simultaneously causes memory exhaustion. Mobile devices with limited GPU memory crash or force layer eviction, causing visual glitches. Limit concurrent animations to 5-10 elements maximum.

Animation Conflicts: Multiple animations targeting the same property create unpredictable behavior. The last animation wins, but intermediate states may flash. Always cancel existing animations before starting new ones, as demonstrated in the AnimationController class.

Transform Origin Misalignment: Scaling or rotating elements without setting appropriate transform-origin creates unexpected visual results. For modal dialogs, set transform-origin: center center. For dropdown menus, use transform-origin: top left.

Z-Index Stacking Issues: Animated elements promoted to compositor layers create new stacking contexts. Elements that previously appeared above the animated element may suddenly render behind it. Explicitly manage z-index values for animated elements and their siblings.

Subpixel Rendering: Transforms can position elements at fractional pixel coordinates, causing blurry text or images. Use transform: translate3d() instead of translate() to force GPU rendering, and round translation values to whole pixels when precision matters:

const roundTransform = (x: number, y: number): string => {
  return `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
};

Animation Timing in Slow Networks: Animations that start before critical resources load create jarring experiences. Coordinate animation timing with resource loading:

const waitForImageLoad = (img: HTMLImageElement): Promise<void> => {
  return new Promise((resolve, reject) => {
    if (img.complete) {
      resolve();
    } else {
      img.addEventListener('load', () => resolve());
      img.addEventListener('error', reject);
    }
  });
};

const animateImageReveal = async (img: HTMLImageElement) => {
  await waitForImageLoad(img);
  await controller.animateElement(img, [
    { opacity: 0, transform: 'scale(0.95)' },
    { opacity: 1, transform: 'scale(1)' }
  ], { duration: 400, easing: 'ease-out' });
};

Best Practices Checklist

  • Animate only compositor-only properties: Use transform and opacity exclusively for performance-critical animations
  • Apply will-change strategically: Add before animation starts, remove after completion to prevent memory bloat
  • Respect prefers-reduced-motion: Provide alternative feedback mechanisms for users who disable animations
  • Use appropriate timing functions: Material Design's cubic-bezier(0.4, 0, 0.2, 1) provides natural-feeling motion for most interactions
  • Keep durations under 400ms: Longer animations feel sluggish; shorter animations feel responsive
  • Cancel conflicting animations: Always clean up existing animations before starting new ones on the same element
  • Profile in production conditions: Test animations on mid-range mobile devices with CPU throttling enabled
  • Coordinate with framework lifecycle: Ensure animations complete before component unmounting or state updates
  • Batch animation starts: Use requestAnimationFrame to synchronize multiple animation starts to the same frame
  • Monitor Core Web Vitals impact: Verify animations don't degrade INP scores below 200ms threshold
  • Implement progressive enhancement: Ensure core functionality works without animations for users with JavaScript disabled or reduced motion preferences

FAQ

What is the difference between CSS transitions and keyframe animations?

CSS transitions animate property changes between two states triggered by events like hover or class changes. Keyframe animations define multi-step animation sequences with precise control over intermediate states, timing, and looping behavior. Use transitions for simple state changes and keyframes for complex, orchestrated animations.

How do CSS animations affect Core Web Vitals in 2025?

Poorly implemented animations that trigger layout or paint operations block the main thread, increasing Interaction to Next Paint (INP) scores. Animations using compositor-only properties (transform, opacity) run on the GPU and don't impact INP. Keep animations under 400ms and use hardware acceleration to maintain INP scores below 200ms.

What is the best way to animate element position in modern browsers?

Use transform: translate() instead of animating top, left, right, or bottom properties. Transforms are compositor-only operations that leverage GPU acceleration, while position properties trigger expensive layout recalculations. For example, use transform: translateX(100px) instead of left: 100px.

When should you avoid using will-change in CSS animations?

Avoid applying will-change to many elements simultaneously or leaving it applied permanently. Each element with will-change consumes GPU memory by maintaining a compositor layer. Apply it just before animations start and remove it after completion. Never use will-change: transform on more than 10-15 elements simultaneously on mobile devices.

How do you handle animation performance on low-end mobile devices?

Detect device capabilities using navigator.hardwareConcurrency and navigator.deviceMemory, then reduce animation complexity accordingly. Disable non-essential animations, reduce durations, or skip animations entirely on devices with fewer than 4 CPU cores or less than 4GB RAM. Always respect prefers-reduced-motion preferences.

What are compositor-only properties and why do they matter?

Compositor-only properties (transform, opacity, filter, backdrop-filter) can be animated entirely on the GPU compositor thread without involving the main JavaScript thread. This means animations remain smooth even when JavaScript execution is blocked. Other properties require layout recalculation or repainting, which blocks the main thread and causes jank.

How do you coordinate CSS animations with React 18 concurrent rendering?

Use the Web Animations API instead of CSS classes for programmatic control. Store animation references in refs, cancel animations in cleanup functions, and await animation.finished promises before state updates. Avoid triggering animations during render—use useLayoutEffect for synchronous DOM measurements and useEffect for animation starts.

Conclusion

Implementing performant CSS animations requires understanding the browser rendering pipeline, choosing appropriate animation techniques, and respecting user preferences. The fundamental principle remains simple: animate only compositor-only properties (transform and opacity) for performance-critical interactions, use transitions for state-based changes, and reserve keyframe animations for complex sequences.

Modern web applications in 2025 face unprecedented performance scrutiny through Core Web Vitals, high-refresh displays, and diverse device capabilities. Teams that master these animation patterns deliver superior user experiences while maintaining excellent performance metrics and search rankings.

Start by auditing existing animations in your application using Chrome DevTools Performance panel. Identify animations triggering layout or paint operations and refactor them to use transforms. Implement the AnimationController pattern for programmatic animation management, add prefers-reduced-motion support, and establish performance budgets for animation durations. These concrete steps will immediately improve application responsiveness and user satisfaction.