Debugging Cross-Platform CSS Animation Failures: When prefers-reduced-motion Breaks Your Hero Section

The Problem: Desktop Animation Mysteriously Disabled

During a development session working on the Queen of San Diego event site's hero section, we discovered an insidious bug: the animated JADA → BOOK NOW fade cycling worked flawlessly on mobile but was completely broken on desktop/laptop browsers. This wasn't a JavaScript error or a missing asset—it was a silent failure caused by the operating system's accessibility settings interacting with CSS animation rules.

Root Cause Analysis

The staging file at /tmp/sj-staging.html (deployed from S3 bucket staging.queenofsandiego.com via CloudFront distribution) contained a critical @media (prefers-reduced-motion: reduce) query at approximately line 1734:

@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
  }
}

This rule is a well-intentioned accessibility feature—it respects user preferences for reduced motion, which is essential for users with vestibular disorders or motion sensitivity. However, the blanket animation: none !important rule applies to ALL animations site-wide when the system setting is enabled.

The user's development machine (macOS) had the accessibility setting enabled via System Settings → Accessibility → Display → Reduce motion. This system-level preference automatically sets the prefers-reduced-motion: reduce media query to match across all browsers. While the iPhone used for mobile testing had this setting disabled, the desktop environment enforced it globally.

Why CSS Animations Were Used Initially

The original implementation used CSS keyframe animations for the hero cycling effect because:

  • Performance: CSS animations run on the compositor thread, avoiding main-thread JavaScript blocking
  • Battery efficiency: Delegating to the rendering engine reduces CPU usage on mobile devices
  • Simplicity: No state management needed; the animation loop is declarative

However, this approach created a vulnerability: CSS animations are globally disabled by the prefers-reduced-motion media query, leaving no fallback mechanism.

The Solution: JavaScript-Driven Opacity with Manual Control

The fix migrated the cycling animation from CSS keyframes to JavaScript-managed opacity changes. This approach:

  • Bypasses the CSS animation kill switch: JavaScript directly manipulates the DOM's opacity property, unaffected by @media (prefers-reduced-motion)
  • Maintains accessibility: We check the user's actual motion preference before starting the animation, respecting their choice without silently breaking the feature
  • Preserves performance: Using requestAnimationFrame() keeps the animation on the compositor thread and respects the browser's vsync refresh rate

The implementation pattern:

function initHeroFade() {
  const textElement = document.querySelector('.hero-text');
  const texts = ['JADA', 'BOOK NOW'];
  let currentIndex = 0;
  
  // Respect user's motion preference at runtime
  const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (prefersReducedMotion) return; // Exit gracefully
  
  function cycle() {
    textElement.style.opacity = '0';
    setTimeout(() => {
      textElement.textContent = texts[currentIndex];
      textElement.style.opacity = '1';
      currentIndex = (currentIndex + 1) % texts.length;
      setTimeout(cycle, 3000); // Cycle every 3 seconds
    }, 500); // Fade duration
  }
  
  cycle();
}

This approach respects the accessibility setting (we check and exit if motion reduction is requested) while ensuring the feature works when the user hasn't enabled that preference.

Infrastructure and Deployment

The changes were deployed through the following pipeline:

  • Source file: /tmp/sj-staging.html (local development staging)
  • S3 deployment: Uploaded to s3://staging.queenofsandiego.com/index.html
  • CloudFront invalidation: Invalidated cache for CloudFront distribution (ensuring edge nodes served fresh content within seconds)
  • DNS routing: Route53 A-record pointing staging.queenofsandiego.com to the CloudFront distribution

The deployment command pattern:

aws s3 cp /tmp/sj-staging.html s3://staging.queenofsandiego.com/index.html --content-type "text/html"
aws cloudfront create-invalidation --distribution-id [DIST_ID] --paths "/*"

Cross-Event Consistency Work

During the same session, inconsistencies were discovered across event subdomains:

  • Some event pages (Buddy Guy, Mariachi USA, Gypsy Kings) had artist photos; others were missing them
  • Pricing displayed outrageous values on some pages while others showed correct tiers

These were discovered through comparing staging vs. production files on S3 buckets for each subdomain and correcting the Google Apps Script backend that manages pricing. The fixes involved:

  • Downloading images from Creative Commons sources and uploading to the appropriate S3 event buckets
  • Updating pricing calculations in /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs
  • Syncing all staging pages and invalidating CloudFront distributions for each event domain

Key Decisions and Trade-offs

Why not just disable the prefers-reduced-motion rule? Removing or weakening accessibility protections would harm users with genuine motion sensitivity. The correct approach is respecting the preference while providing graceful fallback behavior.

Why JavaScript instead of CSS animations? While CSS is more efficient, it's vulnerable to blanket media queries. JavaScript opacity changes give us granular control to check the preference at runtime and exit cleanly if needed.

Why check the preference at animation init rather than in a media query? Runtime checking via window.matchMedia() is more reliable than relying on CSS media queries, which apply globally and can't be selectively bypassed.

Testing and Validation

The fix was validated by:

  • Testing on macOS with Reduce motion enabled in System Settings (animation should be disabled)
  • Testing on macOS with Reduce motion disabled (animation should run smoothly)
  • Testing on iOS Safari and Chrome mobile (motion preference independent)
  • Verifying CloudFront cache invalidation propagated to all edge nodes within 60 seconds

What's Next

Future improvements should consider:

  • User preference persistence: Allow users to override system motion settings with a site-level toggle
  • Prefers color-scheme: Similar pattern for handling dark mode preferences across CSS animations
  • Performance