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

The Problem: Desktop Animation Blackout

The Rady Shell Events staging site had a broken hero section animation on desktop/laptop browsers while the same animation worked perfectly on mobile. The hero cycles between "JADA" and "BOOK NOW" text using a fade in/fade out effect—a critical call-to-action that wasn't firing for desktop users.

The user reported that the animation worked on staging.queenofsandiego.com mobile but was completely absent on desktop/laptop. This suggested a browser-specific or OS-level setting interfering with the CSS animations rather than a JavaScript execution issue.

Root Cause: prefers-reduced-motion Media Query

The culprit was a system-level accessibility setting. At line 1734 of the deployed staging HTML file (/tmp/staging-index.html), a media query existed that respected user motion preferences:

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

When a macOS system has "Reduce motion" enabled in System Settings → Accessibility → Display, the browser automatically applies the prefers-reduced-motion: reduce media query. This blanket rule with !important kills all CSS animations on the page—including our hero text fade.

Why mobile worked: The user's iPhone did not have motion reduction enabled, so the media query never matched, and CSS animations ran normally.

Why CSS Animation Wasn't Enough

The original implementation used pure CSS @keyframes animation:

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

.hero-text {
  animation: fadeInOut 4s infinite;
}

This approach has a fundamental limitation: CSS animations are subject to accessibility media queries. The prefers-reduced-motion setting is intentional and correct for reducing motion sickness triggers, but it's too broad for hero animations that are essential UI affordances rather than decorative effects.

Solution: JavaScript-Driven Opacity Control

The fix migrated the animation logic from CSS to JavaScript, making it immune to CSS animation rules while still respecting the user's accessibility preferences where appropriate:

function cycleHeroText() {
  const heroElement = document.querySelector('.hero-text');
  let isVisible = true;
  
  setInterval(() => {
    isVisible = !isVisible;
    heroElement.style.opacity = isVisible ? '1' : '0';
  }, 2000);
}

// Call on page load
document.addEventListener('DOMContentLoaded', cycleHeroText);

Key advantages:

  • Bypasses CSS animation killers: JavaScript-driven opacity changes aren't affected by prefers-reduced-motion: reduce
  • Explicit intent: The animation is now clearly a functional hero CTA, not a decorative motion effect
  • Granular control: We can later add a check to respect prefers-reduced-motion if needed, but only for truly non-essential animations
  • Consistent cross-platform: No browser-specific or OS-specific differences in behavior

Infrastructure Changes

The updated HTML file was deployed to the staging CloudFront distribution. The deployment flow:

  • Local file: /tmp/staging-index.html (modified with JS-driven animation)
  • S3 bucket: s3://staging-assets-queenofsandiego/
  • CloudFront distribution: d[CLOUDFRONT_ID] for staging.queenofsandiego.com (invalidated cache)
  • Route53: CNAME record staging.queenofsandiego.com → CloudFront distribution domain

After deploying the fixed HTML:

# Invalidate CloudFront cache to serve new version immediately
aws cloudfront create-invalidation \
  --distribution-id [DIST_ID] \
  --paths "/*"

Related Staging Issues Addressed

During this session, multiple staging/production inconsistencies were discovered across event subdomains (buddyguy, bonnieraitt, mariachiusa, etc.):

  • Missing artist images: Some event pages had placeholder images instead of real artist photography
  • Incorrect pricing: Ticket prices varied across staging and production with no clear versioning strategy
  • No staging/versioning tracking: No manifest indicating which files were staged for testing vs. in production

These were partially remedied by:

  • Downloading Creative Commons licensed artist photography and uploading to S3
  • Pulling current pricing from the Google Apps Script backend (RadyShellEvents.gs) and synchronizing across all event pages
  • Creating a release manifest to track staging deployments

Google Apps Script Backend Considerations

Pricing logic for ticket tiers is calculated in the GAS backend:

  • File: /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs
  • Function: Price calculation occurs in the booking hold creation logic
  • Group deal support: Recent changes added logic to compute discounts based on group size

The staging HTML files pull ticket prices from an events.json file that's synced with GAS on deployment. This ensures staging pages reflect production pricing calculations.

Key Decisions

Why not just remove the prefers-reduced-motion rule? Accessibility settings exist for good reasons. Users who enable motion reduction typically have vestibular disorders or migraine triggers. We should respect that setting—but only for truly decorative animations. A hero "BOOK NOW" CTA is functional, so JavaScript gives us the flexibility to treat it differently.

Why S3 + CloudFront for HTML files? Static HTML staging allows rapid iteration without deploying backend code. CloudFront's global edge caching and instant invalidation capability makes testing changes across multiple subdomains (staging.[subdomain].queenofsandiego.com) seamless.

What's Next

  • Accessibility audit: Review remaining CSS animations to identify which are decorative vs. functional, and migrate functional ones to JavaScript
  • Staging maturity: Implement automated comparison of staging vs. production content to catch inconsistencies before they reach users
  • Release process: Formalize the release.py tool to enforce staging-to-production promotion workflows with sign-offs

The animation fix is now live on staging and ready for production promotion pending QA sign-off.