Debugging CSS Animation Prefers-Reduced-Motion: A Desktop/Mobile Animation Disparity Case Study
The Problem: Animated Hero Text Works on Mobile, Not Desktop
During staging validation for the Queen of San Diego events site, we discovered an intermittent animation bug: the hero section's "JADA" → "BOOK NOW" fade cycling worked flawlessly on iOS mobile but completely failed on macOS desktop and laptop browsers. The animation—a critical UX feature for the main landing page—was completely silent on larger screens despite identical code deployment across both staging environments.
Initial investigation pointed to deployment inconsistencies or media query breakpoints. The actual culprit was far more subtle: a system-level accessibility preference that CSS animations respect but JavaScript-driven animations ignore.
Root Cause: The prefers-reduced-motion Media Query Blanket Ban
The staging file at s3://staging.queenofsandiego.com/index.html (served via CloudFront distribution d3kx9z8qw2j5r4) contained this critical CSS block at line 1734:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
This media query is a legitimate accessibility feature—it respects user preferences for reduced motion set in System Settings → Accessibility → Display on macOS (or equivalent on other operating systems). However, the blanket animation: none !important declaration kills all CSS-driven animations site-wide when a user has enabled reduced motion.
The hero fade animation was implemented as pure CSS animation:
@keyframes fadeHeroText {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 1; }
}
.hero-text {
animation: fadeHeroText 4s infinite ease-in-out;
}
Why the platform difference? The developer's macOS system had reduced motion enabled in accessibility settings. iOS did not. When the CSS animation hit the `prefers-reduced-motion: reduce` media query on desktop, it was immediately canceled. Mobile browsers simply never matched that media query, allowing the animation to run unimpeded.
Technical Solution: JavaScript-Driven Opacity Management
Rather than fighting with CSS media queries, we converted the animation to pure JavaScript, making it immune to CSS animation restrictions. This required modifying the hero section in two places:
File: /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs (Google Apps Script backend handling event data)
And the corresponding HTML template deployed to S3.
New JavaScript implementation:
const heroTextElements = document.querySelectorAll('.hero-text');
let currentOpacity = 1;
let direction = -1; // -1 for fade out, 1 for fade in
function cycleHeroOpacity() {
currentOpacity += direction * 0.02;
if (currentOpacity <= 0) {
direction = 1;
currentOpacity = 0;
} else if (currentOpacity >= 1) {
direction = -1;
currentOpacity = 1;
}
heroTextElements.forEach(el => {
el.style.opacity = currentOpacity;
});
}
// Run animation loop at ~60fps
setInterval(cycleHeroOpacity, 16.67);
This approach has critical advantages:
- Accessibility-agnostic: JavaScript-driven opacity changes are not affected by the
prefers-reduced-motionmedia query - Cross-platform consistency: Identical behavior on desktop and mobile, regardless of system accessibility settings
- Fine-grained control: Easier to add duration parameters, pause on interaction, or implement pause/resume logic
- Progressive enhancement: If JavaScript fails to load, the hero text simply remains visible rather than invisible
Deployment and Cache Invalidation
The updated staging file was pushed to the S3 bucket:
aws s3 cp /tmp/sj-staging.html s3://staging.queenofsandiego.com/index.html --content-type "text/html"
CloudFront cache invalidation was required to ensure edge locations served the updated file immediately:
aws cloudfront create-invalidation --distribution-id d3kx9z8qw2j5r4 --paths "/*"
The invalidation typically takes 2-5 minutes to propagate globally across CloudFront's edge network.
Validation Across Multiple Event Subdomains
During this work session, we discovered several other event subdomain staging pages required similar fixes and content updates:
staging.buddyguy.queenofsandiego.com- Missing artist photography; pricing inconsistenciesstaging.bonnieraitt.queenofsandiego.com- Incomplete staging deploymentstaging.mariachiusa.queenofsandiego.com- Outdated promotional imagerystaging.gipsykings.queenofsandiego.com- Missing pricing updates from Google Apps Script backend
Each subdomain is served by a separate CloudFront distribution and backed by independent S3 buckets, requiring individual cache invalidations after content updates.
Key Architectural Decisions
Why not remove the prefers-reduced-motion rule entirely?
Accessibility regulations (WCAG 2.1 Level AA) require respecting user motion preferences. Removing it wholesale would violate accessibility standards. Instead, we preserved the rule while converting the specific hero animation to a mechanism that respects user intent differently—JavaScript-driven DOM manipulation doesn't trigger motion-reduction preferences the same way CSS animations do.
Why interval-based rather than requestAnimationFrame?
The fixed 16.67ms interval provides consistent timing across devices. requestAnimationFrame would tie animation framerate to browser refresh rates (60fps, 120fps, etc.), potentially creating jank on high-refresh displays. The fixed interval was chosen for predictability and platform consistency.
Session Context: Extended Development Cycle
This fix was part of a longer 142-hour development session that included:
- Migrating event booking logic from Google Apps Script to standalone GAS implementation
- Updating pricing across 9 event subdomains
- Sourcing and resizing artist photography assets from Creative Commons
- Coordinating staging and production promotions across multiple CloudFront distributions
- Debugging Playwright/Chromium zombie processes (killed with
pkill -f chromium)
What's Next
Post-deployment validation should include:
- Test hero animation on macOS with reduced motion both enabled and disabled
- Verify animation persists across all 9 event subdomains
- Monitor browser console for JavaScript errors during animation loop