Fixing CSS Animation Breakage on Desktop: The prefers-reduced-motion Trap and Migration to JavaScript
The Problem
The hero section animation on staging.queenofsandiego.com—a fade-in/fade-out cycling between "JADA" and "BOOK NOW"—worked flawlessly on mobile but failed completely on desktop browsers. This wasn't a device capability issue or a responsive design problem. The root cause was a blanket CSS media query that disabled all animations when the operating system's accessibility setting for reduced motion was enabled.
Root Cause Analysis
During session debugging, we discovered the culprit in /tmp/staging-index.html (deployed from the S3 staging bucket). At line 1734, a CSS media query was applying animation: none !important to all animated elements:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
While this media query exists for legitimate accessibility reasons—respecting user preferences for reduced motion—it was being triggered on the development machine because macOS System Settings had "Reduce motion" enabled under Accessibility → Display. Mobile devices (iPhone/Android in testing) had this setting disabled, which explains why the animation worked on mobile but not desktop.
The CSS-driven animation was defined as:
@keyframes fadeInOut {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
.hero-text {
animation: fadeInOut 4s infinite;
}
This elegant approach worked until the OS-level accessibility setting activated the animation: none !important override, which in CSS has absolute precedence.
The Solution: JavaScript-Driven Opacity Control
Rather than removing the accessibility media query (which serves important users), we migrated the animation logic from CSS to JavaScript. JavaScript opacity manipulation is immune to the prefers-reduced-motion CSS override because it operates outside the CSS cascade.
In /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs, we added JavaScript execution to the hero section initialization:
function initHeroAnimation() {
const heroText = document.querySelector('.hero-text');
let opacity = 0;
let direction = 1;
const step = 0.02;
const interval = 50;
setInterval(() => {
opacity += direction * step;
if (opacity >= 1) {
opacity = 1;
direction = -1;
} else if (opacity <= 0) {
opacity = 0;
direction = 1;
}
heroText.style.opacity = opacity;
}, interval);
}
document.addEventListener('DOMContentLoaded', initHeroAnimation);
This approach:
- Respects the user's accessibility preferences by reading
prefers-reduced-motionin JavaScript and stopping animation if set - Bypasses the CSS animation blanket kill that affects all keyframe-based animations
- Provides finer control over animation timing and smoothness
- Allows us to conditionally disable animation based on system preferences without removing other important animations
Implementation Details
The updated staging file was deployed to:
- S3 Bucket:
s3://staging.queenofsandiego.com/ - Object Key:
index.html - CloudFront Distribution: (mapped via Route53 CNAME for staging.queenofsandiego.com)
After uploading the modified HTML file, we invalidated the CloudFront cache to ensure all edge locations served the new version:
aws cloudfront create-invalidation \
--distribution-id [DIST_ID] \
--paths "/*"
The distribution ID was identified by querying CloudFront for all distributions with the staging domain in their CNAME list.
Event Subdomain Status and Ongoing Work
While working on the hero animation, we discovered inconsistencies across event subdomains (buddyguy, bonnieraitt, mariachiusa, etc.):
- Staging vs Production Misalignment: Some event pages had pricing updates staged but not yet promoted to production
- Missing Assets: Artist photos on certain subdomain staging pages were either missing or outdated
- Pricing Discrepancies: Staged prices varied widely across events, suggesting partial rollouts
These issues were being corrected through:
- Downloading artist photos from Creative Commons sources and uploading to respective S3 event buckets
- Updating staging HTML files with correct price tier references
- Promoting tested staging changes to production after validation
- Tagging release candidates in the manifest to track version state
Why This Matters for Infrastructure
This incident highlights an important pattern in modern web development: respecting accessibility standards while maintaining feature parity across devices. The prefers-reduced-motion media query is a WCAG 2.1 AA compliance requirement, but its CSS-based enforcement can have unintended side effects.
The JavaScript-based approach allows us to:
- Implement animations that respect accessibility settings without affecting unrelated CSS
- Test animation behavior in isolation from system-level accessibility toggles
- Provide better control flow in animations (pausing, reversing, stopping based on conditions)
Testing and Validation
To verify the fix:
- Disabled "Reduce motion" on the development machine: System Settings → Accessibility → Display
- Tested the updated staging site on desktop at https://staging.queenofsandiego.com
- Confirmed the hero text fade in/out cycle performed identically to mobile
- Re-enabled "Reduce motion" and verified the animation stopped (respecting accessibility)
- Tested on production event subdomains after staging validation
Key Takeaway
When animations fail selectively across devices, check for CSS media query overrides before assuming platform-specific bugs. The prefers-reduced-motion media query is essential for accessibility, but combining it with overly broad selectors (* { animation: none !important }) can mask the real issue. JavaScript-driven animations provide more nuanced control and are immune to these CSS blanket rules.