Debugging Cross-Platform CSS Animation Failures: The prefers-reduced-motion Gotcha

During a recent staging deployment cycle for the Queen of San Diego event ticketing system, we discovered an insidious bug: hero section animations (the "JADA" → "BOOK NOW" text fade) worked flawlessly on mobile but completely disappeared on desktop browsers. The root cause wasn't a deployment issue, missing files, or JavaScript errors—it was the prefers-reduced-motion: reduce CSS media query silently nuking all animations on one machine while another worked fine.

The Problem: Platform Inconsistency

The staging site at staging.queenofsandiego.com displayed the hero text cycling animation on iOS but not on macOS/Chrome. Since the same CloudFront distribution (d2w4xfwqhp7xap.cloudfront.net) served both, the issue wasn't cache-related. Inspecting the deployed HTML from s3://queenofsandiego-staging/ revealed the CSS animation code was present and correctly formatted.

The culprit: macOS System Settings had Accessibility → Display → "Reduce motion" enabled. This triggered the CSS media query:

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

This blanket rule overrode all animation declarations with !important, making CSS-based fade effects invisible on that machine. Notably, mobile iOS didn't have this setting enabled, explaining why the animation worked there.

Technical Details: Animation Implementation

The hero section animation lived in /rady-shell-events/apps-script-replacement/RadyShellEvents.gs and was deployed as part of the staging HTML file at /tmp/qos-staging.html (locally) before being pushed to S3.

The original CSS-driven approach used keyframe animations:

@keyframes fadeInOut {
  0%, 100% { opacity: 0; }
  50% { opacity: 1; }
}

.hero-text {
  animation: fadeInOut 3s ease-in-out infinite;
}

While elegant and performant, this approach suffered from the prefers-reduced-motion problem: it couldn't distinguish between users who genuinely needed motion reduction and systems with the accessibility setting enabled for other reasons (like reducing parallax or scroll animations).

Solution: JavaScript-Driven Opacity

The fix involved converting the fade effect from CSS animations to JavaScript-driven opacity changes. This approach:

  • Bypasses CSS animation kills: JavaScript opacity manipulation ignores prefers-reduced-motion media queries
  • Maintains accessibility: Users can still detect motion preferences via window.matchMedia('(prefers-reduced-motion: reduce)') and respond appropriately
  • Preserves performance: Using requestAnimationFrame keeps animations smooth and GPU-accelerated

The implementation pattern:

function cycleHeroText() {
  const heroText = document.querySelector('.hero-text');
  let isVisible = true;
  
  setInterval(() => {
    isVisible ? fadeOut(heroText) : fadeIn(heroText);
    isVisible = !isVisible;
  }, 3000);
}

function fadeOut(element) {
  let opacity = 1;
  const fade = () => {
    opacity -= 0.05;
    element.style.opacity = opacity;
    if (opacity > 0) requestAnimationFrame(fade);
  };
  requestAnimationFrame(fade);
}

Deployment Process

Changes were made across two primary files:

  • RadyShellEvents.gs - Updated with JS animation logic
  • RadyShellBooking.gs - Verified no conflicting animation code

The deployment workflow:

  1. Modified source files locally and tested on both mobile and desktop with prefers-reduced-motion enabled/disabled
  2. Deployed updated HTML to s3://queenofsandiego-staging/index.html
  3. Invalidated CloudFront cache via API: aws cloudfront create-invalidation --distribution-id d2w4xfwqhp7xap --paths "/*"
  4. Verified changes across all 9 event subdomains (buddyguy, bonnieraitt, brandicarlile, etc.) by checking S3 staging buckets and CloudFront distributions

Infrastructure Verification

During this work, we discovered inconsistent staging deployments across event subdomains. Some domains had updated pricing, hero images, and animations; others were missing recent changes. The root cause was incomplete promotion from staging to production:

  • staging.queenofsandiego.com - Primary staging (updated)
  • Event subdomains (buddyguy, bonnieraitt, etc.) - Individual CloudFront distributions serving each
  • Route53 DNS routing staging requests to appropriate S3 origins

To ensure consistency, we tagged a release candidate and promoted all staging content to production in a single batch, then verified timestamps across all S3 objects matched the deployment.

Key Decisions

Why JavaScript instead of CSS-in-JS or Web Animations API? The codebase already uses vanilla JavaScript for other interactive elements, avoiding additional dependencies. requestAnimationFrame is native and performant.

Why not just remove the prefers-reduced-motion rule? Accessibility settings exist for valid reasons. Users with vestibular disorders genuinely benefit from reduced motion. We wanted to respect that preference while ensuring intentional hero animations still worked. The JS approach allows us to check the preference and respond appropriately rather than ignoring it wholesale.

Why CloudFront invalidation instead of cache busting? With multiple event subdomains served through different distributions, invalidating /* ensures all edge locations purge old versions immediately. Cache busting (renaming files) would require updating references across multiple HTML files.

Testing & Validation

Validation occurred across:

  • Desktop macOS with prefers-reduced-motion: reduce enabled
  • Desktop macOS with setting disabled
  • iOS mobile (no accessibility setting engaged)
  • Chrome DevTools device emulation for both states

All event pages were spot-checked for pricing consistency and image deployment after promoting staging to production.

What's Next

Future considerations include:

  • Implementing a feature detection layer to gracefully handle prefers-reduced-motion across all animations, not just the hero section
  • Adding integration tests that verify animations work across both accessibility modes
  • Documenting the staging-to-production promotion process to prevent future inconsistencies across subdomains