Frontend

CSS Animation Techniques with JavaScript Triggers

A practical guide to CSS animations triggered by JavaScript covering transitions, keyframes, scroll animations, micro-interactions, and the Web Animations API.

CSS Animation Techniques with JavaScript Triggers

Overview

CSS animations are one of those things that separate a functional website from a polished one. The difference between a card that just appears and one that fades in as you scroll is subtle, but users feel it. After years of building production UIs, I can tell you that the best animations are the ones you barely notice — they just make the experience feel right.

The core principle is simple: let CSS handle the actual animation work while JavaScript controls when and how animations fire. CSS transitions and keyframes run on the compositor thread, which means they can hit 60fps without blocking the main thread. The moment you start animating with JavaScript timers and manual style changes, you are fighting the browser instead of working with it.

This article covers the full spectrum — from basic transitions to scroll-triggered entrance effects, the Web Animations API, and performance optimization. Every example uses real patterns I have shipped in production.

Prerequisites

  • Solid understanding of HTML and CSS (selectors, specificity, the box model)
  • Working knowledge of JavaScript DOM manipulation
  • Familiarity with browser DevTools
  • A modern browser (Chrome, Firefox, Safari, or Edge)

CSS Transitions

Transitions are the simplest form of CSS animation. You define a start state and an end state, and the browser interpolates between them.

A transition has four properties:

.card {
  transition-property: transform;
  transition-duration: 300ms;
  transition-timing-function: ease-out;
  transition-delay: 0ms;
}

/* Shorthand */
.card {
  transition: transform 300ms ease-out 0ms;
}

The transition-property tells the browser which CSS property to animate. You can specify all, but I strongly recommend against it in production — it causes the browser to watch every property for changes, which hurts performance and can produce unexpected animations when you change a property you did not intend to animate.

The timing-function controls the acceleration curve. The built-in options are linear, ease, ease-in, ease-out, and ease-in-out. For UI work, ease-out is almost always what you want — elements decelerate as they arrive at their destination, which feels natural. For exits, use ease-in. You can also define custom curves with cubic-bezier():

.card {
  /* A snappy overshoot effect */
  transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

CSS Keyframe Animations

While transitions handle A-to-B changes, keyframe animations let you define multi-step sequences:

@keyframes slideInUp {
  0% {
    opacity: 0;
    transform: translateY(40px);
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes pulse {
  0%, 100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.05);
  }
}

.element {
  animation-name: slideInUp;
  animation-duration: 600ms;
  animation-timing-function: ease-out;
  animation-fill-mode: both;
  animation-delay: 0ms;
  animation-iteration-count: 1;
}

/* Shorthand */
.element {
  animation: slideInUp 600ms ease-out both;
}

The animation-fill-mode: both is critical. Without it, the element snaps back to its pre-animation state when the animation completes. Setting both means the element retains the final keyframe styles after the animation ends and applies the first keyframe styles before the animation starts (during the delay period).

Triggering Animations with classList

The most reliable pattern for triggering CSS animations from JavaScript is toggling classes. Define your animation styles in CSS, then add or remove classes with JavaScript:

.fade-in {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 500ms ease-out, transform 500ms ease-out;
}

.fade-in.is-visible {
  opacity: 1;
  transform: translateY(0);
}
var element = document.querySelector('.fade-in');

function showElement() {
  element.classList.add('is-visible');
}

function hideElement() {
  element.classList.remove('is-visible');
}

This pattern keeps your animation logic in CSS where it belongs and your trigger logic in JavaScript. It is easy to test, easy to debug, and performs well.

Animating on Scroll with IntersectionObserver

Scroll-triggered animations used to require listening to the scroll event and calculating element positions manually. That approach is terrible for performance. IntersectionObserver is the modern solution — it runs asynchronously and only fires when elements actually enter or leave the viewport:

function initScrollAnimations() {
  var elements = document.querySelectorAll('.animate-on-scroll');

  var observer = new IntersectionObserver(function(entries) {
    entries.forEach(function(entry) {
      if (entry.isIntersecting) {
        entry.target.classList.add('is-visible');
        observer.unobserve(entry.target); // Only animate once
      }
    });
  }, {
    threshold: 0.15, // Trigger when 15% visible
    rootMargin: '0px 0px -50px 0px' // Offset from bottom
  });

  elements.forEach(function(el) {
    observer.observe(el);
  });
}

document.addEventListener('DOMContentLoaded', initScrollAnimations);

The rootMargin with a negative bottom value means elements trigger slightly before they are fully in view, which feels more responsive. The threshold at 0.15 means the callback fires when 15% of the element is visible.

Entrance Animations

Here are three entrance animation patterns I use constantly:

/* Fade In */
.fade-in {
  opacity: 0;
  transition: opacity 600ms ease-out;
}
.fade-in.is-visible {
  opacity: 1;
}

/* Slide Up */
.slide-up {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 500ms ease-out, transform 500ms ease-out;
}
.slide-up.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* Scale In */
.scale-in {
  opacity: 0;
  transform: scale(0.9);
  transition: opacity 400ms ease-out, transform 400ms ease-out;
}
.scale-in.is-visible {
  opacity: 1;
  transform: scale(1);
}

Keep entrance animations under 600ms. Anything longer feels sluggish. For most UI elements, 300-400ms is the sweet spot.

Exit Animations with Event Listeners

Exit animations require listening for transitionend or animationend to know when to actually remove the element from the DOM:

function removeWithAnimation(element) {
  element.classList.add('is-exiting');

  element.addEventListener('transitionend', function handler(e) {
    if (e.target !== element) return; // Ignore bubbled events
    element.removeEventListener('transitionend', handler);
    element.remove();
  });
}
.notification {
  opacity: 1;
  transform: translateX(0);
  transition: opacity 300ms ease-in, transform 300ms ease-in;
}

.notification.is-exiting {
  opacity: 0;
  transform: translateX(100px);
}

Always check e.target in the event handler. Transition events bubble, so a child element's transition will also fire on the parent. This is a common source of bugs where elements get removed mid-animation.

Staggered Animations

Staggered animations — where items animate in sequence with slight delays — create a cascading effect that looks great on lists and grids. Use CSS custom properties to set per-element delays:

.stagger-item {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 400ms ease-out, transform 400ms ease-out;
  transition-delay: calc(var(--stagger-index, 0) * 80ms);
}

.stagger-item.is-visible {
  opacity: 1;
  transform: translateY(0);
}
function initStaggeredAnimation(containerSelector) {
  var container = document.querySelector(containerSelector);
  var items = container.querySelectorAll('.stagger-item');

  items.forEach(function(item, index) {
    item.style.setProperty('--stagger-index', index);
  });

  var observer = new IntersectionObserver(function(entries) {
    entries.forEach(function(entry) {
      if (entry.isIntersecting) {
        var children = entry.target.querySelectorAll('.stagger-item');
        children.forEach(function(child) {
          child.classList.add('is-visible');
        });
        observer.unobserve(entry.target);
      }
    });
  }, { threshold: 0.1 });

  observer.observe(container);
}

Cap stagger delays at around 8-10 items. Beyond that, the last items take too long to appear and users lose patience.

GPU-Accelerated Properties

Not all CSS properties animate equally. transform and opacity are composited by the GPU, which means they can animate at 60fps without triggering layout recalculations. These are the only two properties you should animate in performance-critical paths.

Properties to avoid animating: width, height, top, left, margin, padding, border-width, font-size. These trigger layout recalculations (reflow) on every frame, which tanks performance.

Instead of animating left, animate transform: translateX(). Instead of animating width, animate transform: scaleX(). The visual result is similar but the performance difference is massive.

/* Bad - triggers layout on every frame */
.slide-bad {
  left: 0;
  transition: left 300ms ease-out;
}
.slide-bad.active {
  left: 200px;
}

/* Good - composited on the GPU */
.slide-good {
  transform: translateX(0);
  transition: transform 300ms ease-out;
}
.slide-good.active {
  transform: translateX(200px);
}

will-change for Performance Hints

The will-change property tells the browser to prepare for an animation ahead of time. It promotes the element to its own compositor layer:

.card {
  will-change: transform, opacity;
  transition: transform 300ms ease-out, opacity 300ms ease-out;
}

Use this sparingly. Every element with will-change consumes GPU memory. Apply it only to elements that will actually animate, and ideally add it just before the animation starts and remove it after:

function prepareAnimation(element) {
  element.style.willChange = 'transform, opacity';

  requestAnimationFrame(function() {
    element.classList.add('is-animating');
  });

  element.addEventListener('transitionend', function handler() {
    element.style.willChange = 'auto';
    element.removeEventListener('transitionend', handler);
  });
}

Respecting Reduced Motion Preferences

Some users have vestibular disorders where motion can cause dizziness or nausea. Always respect the prefers-reduced-motion media query:

@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;
  }
}

You can also check this preference in JavaScript:

var prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

function shouldAnimate() {
  return !prefersReducedMotion.matches;
}

// Listen for changes
prefersReducedMotion.addEventListener('change', function(e) {
  if (e.matches) {
    // User enabled reduced motion — cancel running animations
    cancelAllAnimations();
  }
});

This is not optional. It is an accessibility requirement.

Web Animations API

The Web Animations API gives you programmatic control over animations with the same performance benefits as CSS animations:

var element = document.querySelector('.box');

var animation = element.animate([
  { transform: 'translateY(0)', opacity: 1 },
  { transform: 'translateY(-20px)', opacity: 0.5 },
  { transform: 'translateY(0)', opacity: 1 }
], {
  duration: 800,
  easing: 'ease-in-out',
  iterations: Infinity
});

The API returns an Animation object with full playback control:

animation.pause();
animation.play();
animation.reverse();
animation.cancel();
animation.finish();

// Jump to a specific point
animation.currentTime = 400; // 400ms into the animation

// Change playback speed
animation.playbackRate = 2; // Double speed
animation.playbackRate = 0.5; // Half speed

Chaining Animations

You can chain animations sequentially using the finished promise:

function chainAnimations(element) {
  var step1 = element.animate(
    [{ transform: 'scale(1)' }, { transform: 'scale(1.2)' }],
    { duration: 200, fill: 'forwards' }
  );

  step1.finished.then(function() {
    var step2 = element.animate(
      [{ transform: 'scale(1.2)' }, { transform: 'scale(0.95)' }],
      { duration: 150, fill: 'forwards' }
    );
    return step2.finished;
  }).then(function() {
    element.animate(
      [{ transform: 'scale(0.95)' }, { transform: 'scale(1)' }],
      { duration: 100, fill: 'forwards' }
    );
  });
}

This creates a satisfying "pop" effect — scale up, overshoot slightly down, then settle. It is the kind of micro-interaction that makes buttons feel tactile.

Parallax Scrolling Effects

A lightweight parallax effect using IntersectionObserver and scroll position:

function initParallax() {
  var parallaxElements = document.querySelectorAll('[data-parallax]');

  window.addEventListener('scroll', function() {
    var scrollTop = window.pageYOffset;

    parallaxElements.forEach(function(el) {
      var speed = parseFloat(el.getAttribute('data-parallax')) || 0.5;
      var rect = el.getBoundingClientRect();
      var offset = (rect.top + scrollTop - window.innerHeight) * speed;
      el.style.transform = 'translateY(' + (-offset * 0.1) + 'px)';
    });
  }, { passive: true });
}

Always pass { passive: true } to scroll event listeners. This tells the browser the handler will not call preventDefault(), which allows it to scroll smoothly without waiting for your JavaScript to execute.

Loading Spinners and Skeleton Screens

CSS-only loading spinners are straightforward with keyframes:

@keyframes spin {
  to { transform: rotate(360deg); }
}

.spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #e0e0e0;
  border-top-color: #3b82f6;
  border-radius: 50%;
  animation: spin 800ms linear infinite;
}

Skeleton screens with a shimmer effect provide a better loading experience than spinners:

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

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

.skeleton-text {
  height: 16px;
  margin-bottom: 8px;
}

.skeleton-heading {
  height: 24px;
  width: 60%;
  margin-bottom: 16px;
}

Micro-Interactions

Micro-interactions are small animations that provide feedback. They are essential for making interfaces feel responsive:

/* Button press effect */
.btn-interactive {
  transition: transform 150ms ease-out, box-shadow 150ms ease-out;
}
.btn-interactive:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-interactive:active {
  transform: translateY(0);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
  transition-duration: 50ms;
}

/* Form input focus */
.input-animated {
  border: 2px solid #d1d5db;
  transition: border-color 200ms ease-out, box-shadow 200ms ease-out;
}
.input-animated:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
  outline: none;
}

/* Success checkmark */
@keyframes checkmark {
  0% {
    stroke-dashoffset: 24;
  }
  100% {
    stroke-dashoffset: 0;
  }
}

.checkmark-path {
  stroke-dasharray: 24;
  stroke-dashoffset: 24;
}
.checkmark-path.is-complete {
  animation: checkmark 300ms ease-out forwards;
}

Page Transition Animations

For single-page-style transitions without a framework:

function navigateWithTransition(url) {
  var overlay = document.querySelector('.page-transition-overlay');
  overlay.classList.add('is-active');

  overlay.addEventListener('transitionend', function handler() {
    overlay.removeEventListener('transitionend', handler);
    window.location.href = url;
  });
}
.page-transition-overlay {
  position: fixed;
  inset: 0;
  background: #1a1a2e;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 400ms ease-in;
  z-index: 9999;
  pointer-events: none;
}

.page-transition-overlay.is-active {
  transform: scaleX(1);
  pointer-events: all;
}

Performance Profiling in DevTools

Chrome DevTools has a Performance panel that shows you exactly what happens during your animations frame by frame. Here is how to use it:

  1. Open DevTools and go to the Performance tab
  2. Click Record, trigger your animation, then stop recording
  3. Look at the Frames row — each green bar is a frame. Consistent 16.6ms bars mean 60fps
  4. Check for red bars — those are dropped frames
  5. In the Summary tab, look at the time breakdown: Scripting, Rendering, Painting
  6. The Layers panel (more tools menu) shows which elements are on their own compositor layers

If you see long "Recalculate Style" or "Layout" blocks during animation frames, you are animating a property that triggers layout. Switch to transform and opacity.

Complete Working Example

Here is a full landing page combining scroll-triggered entrance animations, a hamburger menu, hover micro-interactions, a loading skeleton, and a modal with backdrop:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Animated Landing Page</title>
  <style>
    /* ========== Reset & Base ========== */
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      color: #1a1a2e;
      line-height: 1.6;
    }

    /* ========== Scroll Entrance Animations ========== */
    .animate-on-scroll {
      opacity: 0;
      transform: translateY(30px);
      transition: opacity 600ms ease-out, transform 600ms ease-out;
      transition-delay: calc(var(--stagger-index, 0) * 100ms);
    }
    .animate-on-scroll.is-visible {
      opacity: 1;
      transform: translateY(0);
    }

    /* ========== Hamburger Menu ========== */
    .nav {
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      padding: 16px 24px;
      background: rgba(255, 255, 255, 0.95);
      backdrop-filter: blur(8px);
      display: flex;
      justify-content: space-between;
      align-items: center;
      z-index: 100;
    }
    .hamburger {
      width: 32px;
      height: 24px;
      position: relative;
      cursor: pointer;
      background: none;
      border: none;
    }
    .hamburger span {
      display: block;
      position: absolute;
      height: 3px;
      width: 100%;
      background: #1a1a2e;
      border-radius: 2px;
      transition: transform 300ms ease-out, opacity 200ms ease-out;
    }
    .hamburger span:nth-child(1) { top: 0; }
    .hamburger span:nth-child(2) { top: 10px; }
    .hamburger span:nth-child(3) { top: 20px; }

    .hamburger.is-open span:nth-child(1) {
      transform: translateY(10px) rotate(45deg);
    }
    .hamburger.is-open span:nth-child(2) {
      opacity: 0;
      transform: scaleX(0);
    }
    .hamburger.is-open span:nth-child(3) {
      transform: translateY(-10px) rotate(-45deg);
    }

    .mobile-menu {
      position: fixed;
      top: 0;
      right: 0;
      width: 300px;
      height: 100vh;
      background: #1a1a2e;
      color: white;
      padding: 80px 32px 32px;
      transform: translateX(100%);
      transition: transform 350ms ease-out;
      z-index: 99;
    }
    .mobile-menu.is-open {
      transform: translateX(0);
    }
    .mobile-menu a {
      display: block;
      color: white;
      text-decoration: none;
      padding: 12px 0;
      font-size: 18px;
      opacity: 0;
      transform: translateX(20px);
      transition: opacity 300ms ease-out, transform 300ms ease-out;
    }
    .mobile-menu.is-open a {
      opacity: 1;
      transform: translateX(0);
    }
    .mobile-menu.is-open a:nth-child(1) { transition-delay: 150ms; }
    .mobile-menu.is-open a:nth-child(2) { transition-delay: 200ms; }
    .mobile-menu.is-open a:nth-child(3) { transition-delay: 250ms; }
    .mobile-menu.is-open a:nth-child(4) { transition-delay: 300ms; }

    .menu-overlay {
      position: fixed;
      inset: 0;
      background: rgba(0, 0, 0, 0.5);
      opacity: 0;
      pointer-events: none;
      transition: opacity 300ms ease-out;
      z-index: 98;
    }
    .menu-overlay.is-open {
      opacity: 1;
      pointer-events: all;
    }

    /* ========== Card Micro-Interactions ========== */
    .card-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
      gap: 24px;
      padding: 0 24px;
      max-width: 1200px;
      margin: 0 auto;
    }
    .card {
      background: white;
      border-radius: 12px;
      padding: 24px;
      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
      transition: transform 250ms ease-out, box-shadow 250ms ease-out;
      cursor: pointer;
    }
    .card:hover {
      transform: translateY(-4px);
      box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12);
    }
    .card:active {
      transform: translateY(-1px);
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
      transition-duration: 100ms;
    }
    .card h3 {
      margin-bottom: 8px;
    }

    /* ========== Skeleton Loading ========== */
    @keyframes shimmer {
      0% { background-position: -200% 0; }
      100% { background-position: 200% 0; }
    }
    .skeleton {
      background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
      background-size: 200% 100%;
      animation: shimmer 1.5s ease-in-out infinite;
      border-radius: 4px;
    }
    .skeleton-card {
      padding: 24px;
      background: white;
      border-radius: 12px;
      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
    }
    .skeleton-title {
      height: 20px;
      width: 70%;
      margin-bottom: 12px;
    }
    .skeleton-line {
      height: 14px;
      margin-bottom: 8px;
    }
    .skeleton-line:last-child {
      width: 50%;
    }
    .skeleton-card {
      transition: opacity 400ms ease-out;
    }
    .skeleton-card.is-hidden {
      opacity: 0;
      position: absolute;
      pointer-events: none;
    }

    /* ========== Modal ========== */
    .modal-backdrop {
      position: fixed;
      inset: 0;
      background: rgba(0, 0, 0, 0.6);
      display: flex;
      align-items: center;
      justify-content: center;
      opacity: 0;
      pointer-events: none;
      transition: opacity 300ms ease-out;
      z-index: 200;
    }
    .modal-backdrop.is-open {
      opacity: 1;
      pointer-events: all;
    }
    .modal-content {
      background: white;
      border-radius: 16px;
      padding: 32px;
      max-width: 500px;
      width: 90%;
      transform: scale(0.9) translateY(20px);
      transition: transform 350ms ease-out;
    }
    .modal-backdrop.is-open .modal-content {
      transform: scale(1) translateY(0);
    }
    .modal-close {
      background: none;
      border: none;
      font-size: 24px;
      cursor: pointer;
      float: right;
      transition: transform 200ms ease-out;
    }
    .modal-close:hover {
      transform: rotate(90deg);
    }

    /* ========== Sections ========== */
    .hero {
      min-height: 80vh;
      display: flex;
      align-items: center;
      justify-content: center;
      text-align: center;
      padding: 120px 24px 80px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
    }
    .hero h1 {
      font-size: 48px;
      margin-bottom: 16px;
    }
    .section {
      padding: 80px 24px;
      max-width: 1200px;
      margin: 0 auto;
    }
    .section h2 {
      font-size: 32px;
      margin-bottom: 32px;
      text-align: center;
    }
    .btn {
      display: inline-block;
      padding: 12px 24px;
      background: #3b82f6;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 16px;
      cursor: pointer;
      transition: transform 150ms ease-out, box-shadow 150ms ease-out;
    }
    .btn:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
    }
    .btn:active {
      transform: translateY(0);
      transition-duration: 50ms;
    }

    /* ========== Reduced Motion ========== */
    @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;
      }
    }
  </style>
</head>
<body>

  <!-- Navigation -->
  <nav class="nav">
    <strong>AnimatedPage</strong>
    <button class="hamburger" id="hamburger" aria-label="Toggle menu">
      <span></span><span></span><span></span>
    </button>
  </nav>
  <div class="menu-overlay" id="menuOverlay"></div>
  <div class="mobile-menu" id="mobileMenu">
    <a href="#features">Features</a>
    <a href="#cards">Cards</a>
    <a href="#loading">Loading</a>
    <a href="#contact">Contact</a>
  </div>

  <!-- Hero -->
  <section class="hero">
    <div>
      <h1 class="animate-on-scroll">Build Beautiful Interfaces</h1>
      <p class="animate-on-scroll" style="font-size: 20px; margin-bottom: 24px;">
        CSS animations powered by JavaScript triggers
      </p>
      <button class="btn animate-on-scroll" id="openModal">Learn More</button>
    </div>
  </section>

  <!-- Cards Section -->
  <section class="section" id="cards">
    <h2 class="animate-on-scroll">Features</h2>
    <div class="card-grid">
      <div class="card animate-on-scroll">
        <h3>Scroll Animations</h3>
        <p>Elements fade in as you scroll down the page using IntersectionObserver.</p>
      </div>
      <div class="card animate-on-scroll">
        <h3>Micro-Interactions</h3>
        <p>Hover effects on cards give immediate visual feedback to the user.</p>
      </div>
      <div class="card animate-on-scroll">
        <h3>Smooth Transitions</h3>
        <p>The hamburger menu slides in with staggered link animations.</p>
      </div>
    </div>
  </section>

  <!-- Skeleton Loading Section -->
  <section class="section" id="loading">
    <h2 class="animate-on-scroll">Loading State</h2>
    <div class="card-grid" id="skeletonContainer">
      <div class="skeleton-card" id="skeleton1">
        <div class="skeleton skeleton-title"></div>
        <div class="skeleton skeleton-line"></div>
        <div class="skeleton skeleton-line"></div>
        <div class="skeleton skeleton-line"></div>
      </div>
      <div class="skeleton-card" id="skeleton2">
        <div class="skeleton skeleton-title"></div>
        <div class="skeleton skeleton-line"></div>
        <div class="skeleton skeleton-line"></div>
        <div class="skeleton skeleton-line"></div>
      </div>
    </div>
    <div style="text-align: center; margin-top: 24px;">
      <button class="btn animate-on-scroll" id="loadContent">
        Simulate Content Load
      </button>
    </div>
  </section>

  <!-- Modal -->
  <div class="modal-backdrop" id="modalBackdrop">
    <div class="modal-content">
      <button class="modal-close" id="closeModal">&times;</button>
      <h2>Welcome</h2>
      <p style="margin-top: 16px;">
        This modal uses a backdrop fade with a scaled content entrance.
        Click outside or press the X to close.
      </p>
    </div>
  </div>

  <script>
    // ========== Scroll Animations ==========
    (function() {
      var elements = document.querySelectorAll('.animate-on-scroll');

      // Assign stagger indices within each parent
      var parentMap = {};
      elements.forEach(function(el) {
        var parentKey = el.parentElement || 'root';
        if (!parentMap[parentKey]) parentMap[parentKey] = 0;
        el.style.setProperty('--stagger-index', parentMap[parentKey]);
        parentMap[parentKey]++;
      });

      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        elements.forEach(function(el) { el.classList.add('is-visible'); });
        return;
      }

      var observer = new IntersectionObserver(function(entries) {
        entries.forEach(function(entry) {
          if (entry.isIntersecting) {
            entry.target.classList.add('is-visible');
            observer.unobserve(entry.target);
          }
        });
      }, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' });

      elements.forEach(function(el) { observer.observe(el); });
    })();

    // ========== Hamburger Menu ==========
    (function() {
      var hamburger = document.getElementById('hamburger');
      var menu = document.getElementById('mobileMenu');
      var overlay = document.getElementById('menuOverlay');

      function toggleMenu() {
        var isOpen = hamburger.classList.contains('is-open');
        hamburger.classList.toggle('is-open');
        menu.classList.toggle('is-open');
        overlay.classList.toggle('is-open');
        document.body.style.overflow = isOpen ? '' : 'hidden';
      }

      hamburger.addEventListener('click', toggleMenu);
      overlay.addEventListener('click', toggleMenu);

      // Close menu on link click
      menu.querySelectorAll('a').forEach(function(link) {
        link.addEventListener('click', function() {
          if (menu.classList.contains('is-open')) toggleMenu();
        });
      });
    })();

    // ========== Skeleton Loading ==========
    (function() {
      var btn = document.getElementById('loadContent');
      var container = document.getElementById('skeletonContainer');

      btn.addEventListener('click', function() {
        var skeletons = container.querySelectorAll('.skeleton-card');
        skeletons.forEach(function(skeleton) {
          skeleton.classList.add('is-hidden');
          skeleton.addEventListener('transitionend', function handler() {
            skeleton.removeEventListener('transitionend', handler);
            skeleton.remove();
          });
        });

        // Simulate network delay then insert real content
        setTimeout(function() {
          var realContent = [
            { title: 'Article One', text: 'Real content loaded from the server.' },
            { title: 'Article Two', text: 'Another piece of content appears.' }
          ];

          realContent.forEach(function(item) {
            var card = document.createElement('div');
            card.className = 'card animate-on-scroll';
            card.innerHTML = '<h3>' + item.title + '</h3><p>' + item.text + '</p>';
            container.appendChild(card);

            // Trigger entrance animation
            requestAnimationFrame(function() {
              card.classList.add('is-visible');
            });
          });
        }, 800);
      });
    })();

    // ========== Modal ==========
    (function() {
      var backdrop = document.getElementById('modalBackdrop');
      var openBtn = document.getElementById('openModal');
      var closeBtn = document.getElementById('closeModal');

      function openModal() {
        backdrop.classList.add('is-open');
        document.body.style.overflow = 'hidden';
      }

      function closeModal() {
        backdrop.classList.remove('is-open');
        document.body.style.overflow = '';
      }

      openBtn.addEventListener('click', openModal);
      closeBtn.addEventListener('click', closeModal);
      backdrop.addEventListener('click', function(e) {
        if (e.target === backdrop) closeModal();
      });

      document.addEventListener('keydown', function(e) {
        if (e.key === 'Escape' && backdrop.classList.contains('is-open')) {
          closeModal();
        }
      });
    })();
  </script>
</body>
</html>

Common Issues and Troubleshooting

Animation not firing after class is added dynamically. When you create an element and immediately add an animation class, the browser batches both operations into a single frame. The element never renders in its "before" state, so there is nothing to transition from. Fix this by forcing a reflow between operations:

var el = document.createElement('div');
el.className = 'fade-in';
document.body.appendChild(el);
el.offsetHeight; // Force reflow
el.classList.add('is-visible');

Or use requestAnimationFrame with a double-nested call for maximum reliability:

requestAnimationFrame(function() {
  requestAnimationFrame(function() {
    el.classList.add('is-visible');
  });
});

Transition events fire multiple times. The transitionend event fires once per animated property. If you transition both opacity and transform, the handler runs twice. Either check e.propertyName to filter for a specific property, or use a flag:

var handled = false;
element.addEventListener('transitionend', function(e) {
  if (handled) return;
  handled = true;
  // Your cleanup code
});

Animations jank on mobile. Mobile GPUs are weaker than desktop GPUs. Avoid animating more than 3-4 elements simultaneously on mobile. Reduce animation complexity and use will-change only on the elements currently animating. Also check for box-shadow transitions — they are surprisingly expensive because the shadow must be repainted on every frame.

Animation replays every time element scrolls into view. If you are using IntersectionObserver, call observer.unobserve(entry.target) after the animation fires. Without this, scrolling the element out of view and back triggers the animation again. Sometimes that is what you want, but for entrance animations it looks odd.

Flickering on elements with will-change. On some devices, promoting too many elements to their own compositor layers causes flickering or renders white boxes. Audit your use of will-change and transform: translateZ(0) (the old GPU promotion hack). Remove it from elements that do not actually animate.

Best Practices

  1. Stick to transform and opacity. These are the only two properties that can be animated without triggering layout or paint. Every other animated property forces the browser to do more work per frame.

  2. Keep durations between 200ms and 500ms for UI transitions. Under 200ms feels abrupt. Over 500ms feels sluggish. Entrance animations can go up to 600ms, but interactions like button presses should be under 200ms.

  3. Always include prefers-reduced-motion support. This is an accessibility requirement, not an optional enhancement. A global reset rule (shown earlier) handles most cases.

  4. Use IntersectionObserver instead of scroll events. Scroll event listeners fire at 60+ times per second and run on the main thread. IntersectionObserver is asynchronous and only fires when visibility changes.

  5. Avoid animation-fill-mode: forwards as a layout mechanism. If an element needs to stay in its final animated position permanently, set those styles directly on the element after the animation completes. Using forwards can cause subtle bugs with specificity and make debugging harder.

  6. Test on real devices. Animations that run smoothly on your development machine with a high-refresh-rate monitor and dedicated GPU may stutter on a three-year-old Android phone. The Performance panel in DevTools can throttle CPU to simulate slower devices.

  7. Batch DOM reads and writes. Interleaving reads (like getBoundingClientRect()) and writes (like setting style.transform) causes forced synchronous layouts. Read all values first, then write all changes.

  8. Use requestAnimationFrame for JavaScript-driven animations. Never use setTimeout or setInterval for animation. requestAnimationFrame synchronizes with the browser's paint cycle, giving you a stable 16.6ms frame budget.

References

Powered by Contentful