Front-end Engineering Lab

List Animation Performance

Use will-change CSS property to optimize GPU-accelerated animations in dynamic lists.

List Animation Performance

Problem

Animating list items (fade in, slide, expand) can cause jank and dropped frames because the browser must recalculate layouts and compositing on every frame.

Solution

Use the CSS property will-change to hint the browser about upcoming animations. This promotes the element to its own GPU layer, enabling hardware-accelerated rendering.

type AnimationStyle = 'fade' | 'slide' | 'scale' | 'none';

interface ListItemAnimation {
  element: HTMLElement;
  style: AnimationStyle;
  duration: number;
}

class ListAnimationManager {
  private animatingElements = new Set<HTMLElement>();

  /**
   * Prepare an element for animation by adding will-change
   * This should be called BEFORE the animation starts
   */
  private prepareAnimation(element: HTMLElement, properties: string[]): void {
    element.style.willChange = properties.join(', ');
    this.animatingElements.add(element);
  }

  /**
   * Clean up will-change after animation completes
   * Keeping will-change active consumes memory
   */
  private cleanupAnimation(element: HTMLElement): void {
    element.style.willChange = 'auto';
    this.animatingElements.delete(element);
  }

  /**
   * Animate a single list item
   */
  public animateItem(config: ListItemAnimation): Promise<void> {
    return new Promise((resolve) => {
      const { element, style, duration } = config;

      // Set up animation properties based on style
      let properties: string[] = [];
      let animationClass = '';

      switch (style) {
        case 'fade':
          properties = ['opacity'];
          animationClass = 'fade-in';
          break;
        case 'slide':
          properties = ['transform', 'opacity'];
          animationClass = 'slide-in';
          break;
        case 'scale':
          properties = ['transform', 'opacity'];
          animationClass = 'scale-in';
          break;
        default:
          resolve();
          return;
      }

      // Prepare element for animation
      this.prepareAnimation(element, properties);

      // Small delay to ensure will-change is applied
      requestAnimationFrame(() => {
        element.style.animationDuration = `${duration}ms`;
        element.classList.add(animationClass);

        // Clean up after animation
        const cleanup = (): void => {
          this.cleanupAnimation(element);
          element.removeEventListener('animationend', cleanup);
          resolve();
        };

        element.addEventListener('animationend', cleanup, { once: true });
      });
    });
  }

  /**
   * Animate multiple items with stagger effect
   */
  public async animateList(
    items: HTMLElement[],
    style: AnimationStyle,
    duration: number,
    staggerDelay: number
  ): Promise<void> {
    const animations = items.map((element, index) => {
      return new Promise<void>((resolve) => {
        setTimeout(() => {
          this.animateItem({ element, style, duration }).then(resolve);
        }, index * staggerDelay);
      });
    });

    await Promise.all(animations);
  }

  /**
   * Force cleanup all animations (useful on unmount)
   */
  public cleanup(): void {
    this.animatingElements.forEach((element) => {
      this.cleanupAnimation(element);
    });
  }
}

// CSS to be included in your stylesheet
const animationStyles = `
@keyframes fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

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

@keyframes scale-in {
  from {
    transform: scale(0.9);
    opacity: 0;
  }
  to {
    transform: scale(1);
    opacity: 1;
  }
}

.fade-in {
  animation-name: fade-in;
  animation-timing-function: ease-out;
  animation-fill-mode: both;
}

.slide-in {
  animation-name: slide-in;
  animation-timing-function: ease-out;
  animation-fill-mode: both;
}

.scale-in {
  animation-name: scale-in;
  animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
  animation-fill-mode: both;
}
`;

// Practical usage
function renderListWithAnimation(
  container: HTMLElement,
  items: string[]
): ListAnimationManager {
  const manager = new ListAnimationManager();

  // Create list items
  const elements = items.map((text) => {
    const li = document.createElement('li');
    li.textContent = text;
    li.style.opacity = '0'; // Start invisible
    container.appendChild(li);
    return li;
  });

  // Animate with stagger
  manager.animateList(elements, 'slide', 400, 50);

  return manager;
}

Performance Note

The will-change property tells the browser to prepare for changes, enabling GPU acceleration. This results in:

  • Smooth 60fps animations: offloading work to the GPU
  • No layout thrashing: elements on separate layers don't trigger reflows
  • Reduced paint time: compositing happens on the GPU

Important: Only use will-change during animations. Keeping it active consumes memory because the browser maintains separate GPU layers. Always clean up with will-change: auto after the animation completes.

For staggered list animations (items appearing one after another), use small delays (50-100ms) between items. This creates a polished, professional feel without making users wait.

On this page