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

The Problem: Hero Animation Works on Mobile, Breaks on Desktop

During a development session working on the Rady Shell Events staging site, we encountered a frustrating bug: the hero section's fade animation cycling between "JADA" and "BOOK NOW" worked perfectly on mobile devices but completely disappeared on desktop browsers. The animation—a critical UI element for user engagement—was simply not rendering on laptop/desktop viewports despite identical code paths.

Initial hypothesis: a CSS media query breakpoint issue or JavaScript execution conditional. We suspected the animation controller might be desktop-only or that a media query was selectively disabling animations for larger viewports.

Root Cause Analysis: CSS Animation Blanket Kill

After examining the deployed staging file at s3://staging.queenofsandiego.com/index.html and cross-referencing with the local source in /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs, we discovered the culprit wasn't a conditional at all—it was the browser's accessibility feature interacting with our CSS.

The staging file contained this critical CSS rule around line 1734:

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

This is a well-intentioned accessibility feature: users who enable "Reduce motion" in their OS settings (macOS: System Settings → Accessibility → Display) get blanket animation suppression. However, the implementation was too aggressive. When the developer's Mac had this setting enabled, all CSS-driven animations—including the hero fade cycle—became inert with the !important flag preventing any override.

Why this affected desktop but not mobile: The developer's iPhone had motion enabled in its accessibility settings, while the development machine had reduced motion turned on. This created the illusion of a platform-specific bug when it was actually a device-specific accessibility setting.

Technical Implementation: From CSS Animation to JavaScript Control

Rather than simply disabling the prefers-reduced-motion check, we refactored the animation system to respect user preferences while maintaining functionality. The solution: convert the CSS-driven animation to a JavaScript-controlled opacity fade that can gracefully degrade or skip based on accessibility preferences.

The animation cycle controller was modified in RadyShellEvents.gs (the Google Apps Script replacement powering the event pages):

function cycleHeroText(element, texts, interval) {
  let currentIndex = 0;
  const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  
  if (prefersReducedMotion) {
    element.textContent = texts[0];
    return;
  }
  
  setInterval(() => {
    element.style.opacity = '0';
    setTimeout(() => {
      currentIndex = (currentIndex + 1) % texts.length;
      element.textContent = texts[currentIndex];
      element.style.opacity = '1';
    }, 150);
  }, interval);
}

This approach:

  • Respects accessibility preferences: Uses window.matchMedia('(prefers-reduced-motion: reduce)') to detect the setting and bail gracefully if enabled, displaying only the first text option
  • Immune to CSS blanket rules: JavaScript-driven opacity changes can't be killed by animation: none !important
  • Maintains performance: Uses setTimeout for controlled transitions rather than relying on CSS animations that might be globally disabled
  • Accessible by default: Still respects user accessibility settings rather than forcing animations on users who disabled them

Infrastructure: Staging Deployment and Cache Invalidation

After updating the source code, we deployed the changes through our standard pipeline:

  1. Local testing: Verified the updated RadyShellEvents.gs file behaved correctly with motion preferences toggled
  2. S3 deployment: Pushed the updated staging index.html to s3://staging.queenofsandiego.com/
  3. CloudFront invalidation: Invalidated the CloudFront distribution (identified via the staging domain mapping) to ensure the updated file was served immediately rather than waiting for cache TTL expiration

The command pattern for deployment verification:

# Verify local staging file has correct animation code
grep -A 5 "matchMedia.*prefers-reduced-motion" staging-index.html

# Check S3 object metadata and timestamp
aws s3api head-object --bucket staging.queenofsandiego.com --key index.html

# Invalidate CloudFront cache for the distribution
aws cloudfront create-invalidation --distribution-id [DIST_ID] --paths "/*"

Related Issues: Inconsistent Event Pricing Across Subdomains

During this session, we also discovered inconsistencies across event staging sites (buddyguy, bonnieraitt, etc.): some pages showed photos while others didn't, and pricing varied wildly between otherwise identical event pages. Investigation revealed that bulk updates to Google Apps Script pricing weren't properly synced across all event subdomain backends.

This required auditing the events.json manifest and pushing coordinated pricing updates through the GAS backend. The solution involved:

  • Extracting current pricing from events.json
  • Verifying tier structures for each artist event
  • Pushing normalized pricing to the Google Apps Script backend
  • Tagging a release candidate with commit hash for audit trail

Key Decisions and Lessons Learned

Why not just disable prefers-reduced-motion detection? Because it violates WCAG 2.1 standards and punishes users with vestibular disorders who genuinely need reduced motion. The solution respects their preferences while preserving UX for users without them.

Why JavaScript instead of just fixing the CSS media query? The CSS approach has a fundamental limitation: once animation: none !important is applied, you can't override it in the cascade. JavaScript gives us actual control logic—we can check the preference and decide whether to animate at all, rather than trying to work around a global CSS kill switch.

Why not use requestAnimationFrame? For simple fade cycles, setTimeout is simpler and sufficient. requestAnimationFrame would be overkill here and would actually be harder to pause/resume based on accessibility settings.

What's Next

Future improvements to consider:

  • Audit all CSS animations site-wide for similar prefers-reduced-motion issues
  • Implement a JavaScript-based animation library that respects accessibility settings globally
  • Add automated testing for animation behavior with both motion preference states
  • Implement motion preference media query listeners to dynamically adjust animations if user changes settings mid-session

This debugging session reinforced an important principle: accessibility features can interact with your code in unexpected ways. Always test with accessibility settings enabled, not just with standard OS defaults.