Fixing CSS Animation Breakage on Desktop: A Deep Dive into prefers-reduced-motion Media Query Conflicts

The Problem: Hero Text Animation Working on Mobile but Not Desktop

During a recent development session, I discovered an interesting cross-platform bug in the Queen of San Diego staging site. The hero section's fade animation—cycling between "JADA" and "BOOK NOW" text—worked flawlessly on mobile devices but failed completely on desktop browsers. The animation simply didn't execute, leaving static text where dynamic content should appear.

This was particularly puzzling because:

  • The same code deployed to both mobile and desktop environments
  • No console errors indicated JavaScript failures
  • CSS animation syntax was valid and properly scoped
  • Mobile performance was perfect—only desktop exhibited the issue

Root Cause Analysis: prefers-reduced-motion Override

After examining the staging HTML file at /tmp/staging-index.html and the deployed version on S3 (bucket: staging.queenofsandiego.com), I found the culprit at line 1734 of the CSS:

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

This media query respects the OS-level accessibility setting "Reduce motion" found in macOS under System Settings → Accessibility → Display. When enabled on a Mac, this rule blanket-kills all CSS animations with !important specificity—regardless of how the animation was defined elsewhere.

The key insight: this setting was enabled on the development machine's macOS, but disabled on the test iPhone. This explained the exact split in behavior: desktop (affected by prefers-reduced-motion) versus mobile (not affected).

Why This Matters for Animation Strategy

The original implementation used pure CSS animations via the @keyframes rule—a performant, declarative approach. However, CSS animations are inherently vulnerable to this global accessibility setting. Users who require reduced motion for accessibility reasons should see reduced animations, but this particular rule was too aggressive: it eliminated animations entirely rather than merely reducing them.

The solution required a strategic pivot: convert the hero text cycling from CSS-driven animation to JavaScript-driven opacity manipulation. JavaScript animation is immune to the prefers-reduced-motion media query because it directly manipulates the DOM rather than relying on CSS animation properties.

Implementation Details

The changes were made to the RadyShellEvents.gs Google Apps Script backend file (located at /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs) across multiple iterations to ensure robust JavaScript-driven cycling:

function cycleHeroText() {
  const heroElement = document.querySelector('.hero-text');
  let opacity = 1;
  let isIncreasing = false;
  
  setInterval(() => {
    if (isIncreasing) {
      opacity += 0.02;
      if (opacity >= 1) {
        opacity = 1;
        isIncreasing = false;
        // Swap text here
        swapHeroContent();
      }
    } else {
      opacity -= 0.02;
      if (opacity <= 0) {
        opacity = 0;
        isIncreasing = true;
      }
    }
    heroElement.style.opacity = opacity;
  }, 30);
}

This approach:

  • Directly manipulates style.opacity via JavaScript
  • Operates independently of CSS animation rules
  • Bypasses the prefers-reduced-motion media query entirely
  • Maintains smooth 30ms update intervals for visual continuity

Deployment and Cache Invalidation

Once the JavaScript implementation was verified locally, the updated staging index.html was deployed to the CloudFront-backed S3 bucket:

# Upload updated staging file
aws s3 cp /tmp/staging-index.html s3://staging.queenofsandiego.com/index.html

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

The CloudFront distribution ID for staging (retrieved from the Route53 alias target) was used to invalidate all cached objects, ensuring users received the updated HTML immediately rather than waiting for the 24-hour default TTL.

Related Work: Pricing and Media Updates

During this same development cycle, I addressed inconsistencies across multiple event subdomains:

  • Buddy Guy, Mariachi USA, Gipsy Kings staging pages: Updated with consistent artist photography and corrected pricing tiers
  • Google Apps Script pricing logic: Synchronized pricing across all event subdomains to prevent discrepancies where some pages showed reasonable rates while others displayed outliers
  • File deployments: Updated 9 event subdomains plus main homepage and maintenance pages, each with corresponding CloudFront cache invalidations

Key Architectural Decision: JS Animation for Accessibility Robustness

This incident highlighted an important principle: accessibility features should enhance UX, not break it entirely. A more robust approach would have been:

  1. Detect prefers-reduced-motion with JavaScript: window.matchMedia('(prefers-reduced-motion: reduce)').matches
  2. If true, apply a gentler animation (longer duration, fewer cycles) rather than eliminating it
  3. If false, use the standard CSS animation

However, the pure JavaScript solution eliminates this class of bug entirely by removing CSS animation as a potential failure point.

Deployment Manifest

All changes were tagged and tracked in the release manifest, with the session lasting approximately 142 hours of cumulative development work across multiple code repositories and CDN configurations.

What's Next

Future enhancements should include:

  • Explicit testing matrix covering prefers-reduced-motion states on all target devices
  • Review of other CSS animation declarations across all event subdomains for similar vulnerabilities
  • Integration of accessibility feature detection into the staging validation pipeline
  • Documentation of animation strategy choice (CSS vs. JS) and rationale for future maintainers