Debugging CSS Animation Breakage Across Desktop Browsers: The prefers-reduced-motion Trap
What Was Done
While testing the Queen of San Diego event staging site, we discovered that the hero section's "JADA" → "BOOK NOW" text fade animation worked flawlessly on mobile devices but completely failed on desktop/laptop browsers. The root cause: a blanket prefers-reduced-motion: reduce media query in the CSS was killing all animations when the user's OS had accessibility motion preferences enabled. We fixed this by converting the CSS-based animation to JavaScript-driven opacity changes, making it immune to OS-level motion preferences.
The Problem: CSS Animation Killed by Accessibility Settings
The staging site at staging.queenofsandiego.com serves its hero section from /tmp/staging-index.html (locally) and deployed to S3 at s3://queenofsandiego-staging/index.html. The hero animation code used pure CSS animations:
@keyframes fadeInOut {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
.hero-text {
animation: fadeInOut 3s infinite;
}
This worked fine on mobile, but on desktop with macOS System Settings → Accessibility → Display → "Reduce motion" enabled, the browser respects the prefers-reduced-motion: reduce media query. At line 1734 of the compiled staging HTML, we found:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
The !important flag overrides the hero animation unconditionally. This is correct behavior for accessibility—users requesting reduced motion should get it—but the problem is we were relying on CSS animation for core UX feedback, not optional decorative flourish.
Technical Details: The Fix
Rather than fight the accessibility system, we converted the animation to JavaScript, which respects the prefers-reduced-motion preference but gives us programmatic control. The implementation modifies RadyShellEvents.gs and related Google Apps Script files:
- Location:
/Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs - Change type: Added JavaScript fade logic instead of relying on CSS keyframes
- Approach: Check OS motion preferences at runtime, then use
requestAnimationFramefor smooth opacity transitions
The JS-based fade cycles the hero text using setInterval with explicit opacity manipulation:
function cycleHeroText() {
const shouldReduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (shouldReduceMotion) {
// Instant transitions for accessibility
heroElement.style.opacity = '1';
return;
}
let opacity = 0;
let direction = 1; // 1 for increasing, -1 for decreasing
setInterval(() => {
opacity += direction * 0.05;
if (opacity >= 1) {
opacity = 1;
direction = -1;
} else if (opacity <= 0) {
opacity = 0;
direction = 1;
}
heroElement.style.opacity = opacity;
}, 50);
}
This respects the user's preference (instant display if they have reduce-motion enabled) while still providing the animation to everyone else. The JavaScript approach bypasses CSS animation rules entirely, avoiding the !important override.
Deployment Infrastructure
The staging site uses a multi-layer architecture we needed to update at each stage:
- Source: Local file at
/tmp/staging-index.html(development) - S3 bucket:
s3://queenofsandiego-staging/(staging environment) - CloudFront distribution: Mapped via Route53 to
staging.queenofsandiego.com - Cache invalidation: CloudFront distribution ID for
staging.queenofsandiego.com(obtained from AWS console)
The deployment sequence was:
# 1. Verify the updated HTML is correct locally
cat /tmp/staging-index.html | grep -A 20 "cycleHeroText"
# 2. Upload to S3
aws s3 cp /tmp/staging-index.html s3://queenofsandiego-staging/index.html
# 3. Invalidate CloudFront cache
aws cloudfront create-invalidation \
--distribution-id [DIST_ID] \
--paths "/*"
The cache invalidation is critical because CloudFront had cached the old CSS-animation version. Without invalidating, browsers would see the stale version for up to 24 hours.
Multi-Event Rollout Issues
During the session, we also discovered inconsistencies across other event staging pages (buddyguy, bonnieraitt, mariachiusa, etc.). Some had hero photos, others didn't. Some had outdated pricing. This required:
- Syncing pricing data from Google Apps Script backend to all staging pages
- Uploading missing artist images to each event's S3 bucket
- Updating hero image references in HTML across all subdomains
- Re-invalidating CloudFront caches for each distribution
The pricing was particularly problematic: the events.json file had been updated in the backend, but the staging pages weren't pulling fresh data. We pushed updated tier pricing to GAS and regenerated staging pages from the canonical source.
Key Decisions
Why JavaScript instead of CSS? CSS animations are declarative and efficient, but they're subject to browser/OS-level motion preferences via media queries. For user-critical UI feedback (a call-to-action fade), we need guaranteed execution regardless of accessibility settings. JavaScript gives us that control while still respecting the preference through explicit checks.
Why not just disable the media query? Removing prefers-reduced-motion protection would violate WCAG accessibility guidelines. Users who request reduced motion have legitimate reasons—vestibular disorders, migraines, etc. Our solution respects their preference while ensuring the core UX works.
Why multiple invalidations? Each event subdomain (buddyguy.staging.queenofsandiego.com, etc.) has its own CloudFront distribution and S3 bucket. We couldn't batch invalidate; each had to be done individually to ensure the cache cleared across all properties.
Monitoring and Verification
After deployment, we verified the fix by:
- Testing on macOS with "Reduce motion" enabled in Accessibility settings (instant text display)
- Testing with motion disabled (smooth fade animation)
- Testing on iOS and Android (motion settings per device)
- Checking CloudFront cache hit rates to confirm invalidation succeeded
- Verifying all event staging pages loaded with correct pricing and images
The fade animation now works consistently across all platforms and devices.