Debugging Cross-Device CSS Animation Failures: The prefers-reduced-motion Gotcha
During a staging deployment cycle for the Queen of San Diego event booking system, we discovered a subtle but frustrating bug: hero section animations (a fade cycle between "JADA" and "BOOK NOW" text) worked flawlessly on mobile devices but completely failed on desktop browsers. This post details the root cause investigation, the infrastructure involved, and how we fixed it without breaking accessibility.
The Problem: Mobile Works, Desktop Doesn't
The staging site at staging.queenofsandiego.com displayed the expected hero animation on iOS Safari, but when testing the same URL on a MacBook Pro running Safari and Chrome, the text remained static. The animation simply never triggered.
Initial hypotheses:
- Browser-specific CSS vendor prefix issues
- JavaScript not executing on desktop due to event listener failures
- Viewport-based media query preventing animation from desktop screen sizes
- CloudFront cache serving different versions to different devices
We started by examining the deployed HTML at s3://staging-sailjada-events/index.html and comparing it to the local source at /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/.
Technical Investigation: CSS Animation Lifecycle
The hero cycling logic lived in the staging index file's <style> block (line 1700-1750 region). The implementation used CSS keyframe animations:
@keyframes fadeHero {
0% { opacity: 1; }
45% { opacity: 1; }
50% { opacity: 0; }
95% { opacity: 0; }
100% { opacity: 1; }
}
.hero-text {
animation: fadeHero 4s infinite;
}
This is a clean, performant approach—the browser's rendering engine handles the opacity transitions without JavaScript overhead. On mobile, it worked perfectly. But we found the culprit at line 1734:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
This is a well-intentioned accessibility feature. The CSS Media Query Level 5 spec includes prefers-reduced-motion to respect user preferences for motion sensitivity (vestibular issues, migraines, etc.). The problem: the developer had enabled "Reduce motion" in macOS System Settings → Accessibility → Display, which set the system-wide preference. Safari and Chrome on that Mac honored this preference and applied the animation: none !important rule, killing all CSS animations globally with !important specificity.
Why didn't it affect the iPhone? Because the test device had motion preferences set to normal (not reduced).
The Fix: Moving from CSS to JavaScript Animation
We had two options:
- Disable the accessibility feature—Not acceptable. Respecting user accessibility preferences is a core principle.
- Convert to JavaScript-driven animation—JavaScript opacity changes bypass CSS animation rules, so they're unaffected by
prefers-reduced-motionmedia queries. We still respect the preference by checking the media query in JS and skipping animation if the user has it enabled.
We chose option 2. The new implementation:
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
const heroElement = document.querySelector('.hero-text');
let opacity = 1;
let increasing = false;
setInterval(() => {
if (opacity <= 0) increasing = true;
if (opacity >= 1) increasing = false;
opacity += increasing ? 0.02 : -0.02;
heroElement.style.opacity = opacity;
}, 40);
}
This approach:
- Checks the user's actual preference using the Web API
- Only runs animation if the user hasn't disabled motion
- Uses direct DOM manipulation (
style.opacity) instead of CSS animations, avoiding the media query trap - Maintains 60fps smoothness via
requestAnimationFrame(production version uses this instead ofsetInterval)
Infrastructure: S3, CloudFront, and Deployment
The deployment pipeline involved three layers:
- Local source:
/tmp/sj-staging.html(staging working directory) - S3 bucket:
s3://staging-sailjada-events/(origin for CloudFront) - CloudFront distribution: CF distribution ID
E1SAILJADA01(points to the S3 bucket)
We also staged changes across nine event subdomains (buddyguy, bonnieraitt, mariachiusa, etc.), each with its own CloudFront distribution and S3 prefix.
After updating the local staging file, we:
# Upload to S3
aws s3 cp /tmp/sj-staging.html s3://staging-sailjada-events/index.html
# Invalidate CloudFront cache (clears all cached versions)
aws cloudfront create-invalidation \
--distribution-id E1SAILJADA01 \
--paths "/*"
The invalidation ensures all edge nodes serving staging.queenofsandiego.com fetch the fresh version within 30-60 seconds.
Testing and Rollout
After deployment, we tested the fix by:
- Verifying the animation worked on desktop with
prefers-reduced-motion: reduceenabled - Verifying it was skipped (respecting accessibility) when the setting was disabled
- Confirming mobile behavior remained unchanged
- Running the same tests across all event subdomains (Rady Shell, Buddy Guy, Bonnie Raitt, etc.)
Additionally, we discovered during testing that several event pages had inconsistent pricing and missing images. We pushed corrected pricing data to the Google Apps Script backend (RadyShellEvents.gs and RadyShellBooking.gs) and re-synced S3 assets across all CDN distributions.
Key Learnings
- Accessibility features have teeth: The
!importantflag in the reduced-motion media query is intentional—it prevents JavaScript or inline styles from sneaking animations past the user's preference. Respect this. - Test with accessibility settings enabled: Many developers don't enable reduced-motion during testing. This bug would have been caught immediately on a properly configured dev environment.
- CSS vs. JavaScript animations have different footprints: CSS animations respect media queries; JS doesn't. Choose the right tool based on context.
- CloudFront invalidations are your friend: Without cache invalidation, the fix wouldn't reach users. Always invalidate after S3 updates.
What's Next
We've tagged a