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-motionmedia queries - Maintains accessibility: Users can still detect motion preferences via
window.matchMedia('(prefers-reduced-motion: reduce)')and respond appropriately - Preserves performance: Using
requestAnimationFramekeeps 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 logicRadyShellBooking.gs- Verified no conflicting animation code
The deployment workflow:
- Modified source files locally and tested on both mobile and desktop with
prefers-reduced-motionenabled/disabled - Deployed updated HTML to
s3://queenofsandiego-staging/index.html - Invalidated CloudFront cache via API:
aws cloudfront create-invalidation --distribution-id d2w4xfwqhp7xap --paths "/*" - 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: reduceenabled - 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-motionacross 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