Debugging CSS Animation Breakage on Desktop: The prefers-reduced-motion Gotcha
During a staging deployment cycle for event booking pages across multiple artist subdomains, we encountered a subtle but critical issue: CSS animations working flawlessly on mobile devices but completely absent on desktop browsers. This post walks through the diagnosis, root cause analysis, and the architectural decision to migrate from CSS-driven animations to JavaScript-based opacity manipulation.
The Problem: Inconsistent Animation Behavior Across Form Factors
The hero section of staging.queenofsandiego.com contains a text cycling effect that fades the artist name (e.g., "JADA") in and out, replacing it with a call-to-action button ("BOOK NOW"). This animation rendered perfectly on mobile devices but was completely absent on desktop/laptop browsers accessing the same staging site.
Initial hypotheses included:
- Mobile-only CSS media queries hiding the animation on larger screens
- JavaScript errors preventing execution on desktop
- Browser-specific CSS support differences
- Viewport-based animation triggers
None of these held up under investigation. The real culprit was far more insidious.
Root Cause: System-Level Accessibility Settings Override CSS Animations
The development machine running macOS had "Reduce motion" enabled in System Settings → Accessibility → Display. This setting broadcasts the CSS media query prefers-reduced-motion: reduce to all browsers on that machine.
Examination of the staging file deployed to S3 (s3://[staging-bucket]/staging-index.html) revealed this critical CSS rule at line 1734:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
This blanket rule applies animation: none !important to every element in the DOM when the accessibility preference is detected. The !important flag ensures it overrides all other animation declarations, making it impossible to bypass through specificity.
Why this worked on mobile: The iPhone testing device had motion preferences set to normal (motion enabled), so the prefers-reduced-motion media query never matched.
Why this is correct behavior: The prefers-reduced-motion` media query exists specifically to respect user accessibility preferences. Users who experience motion sensitivity or vestibular disorders need this protection. The implementation was not buggy—it was working as designed. We just didn't expect to trigger it during development.
Technical Details: The Animation Architecture
The original implementation used CSS keyframe animations to drive the text cycling:
@keyframes fadeInOut {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
.hero-text {
animation: fadeInOut 4s ease-in-out infinite;
}
This approach is elegant and performant—the browser's rendering engine handles all frame updates without JavaScript intervention. However, it's vulnerable to any CSS rule that disables animations globally, including accessibility overrides.
The Solution: JavaScript-Driven Opacity Manipulation
We migrated the animation logic to JavaScript, which directly manipulates the DOM element's opacity style. This approach bypasses CSS animation rules entirely and is immune to media query-based animation killers.
The replacement function in /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs now manages opacity transitions:
function cycleHeroText(element, duration) {
let opacity = 0;
const step = 0.05;
const interval = (duration * 1000) / (1 / step);
let increasing = true;
setInterval(() => {
if (increasing) {
opacity += step;
if (opacity >= 1) increasing = false;
} else {
opacity -= step;
if (opacity <= 0) increasing = true;
}
element.style.opacity = Math.min(1, Math.max(0, opacity));
}, interval);
}
This runs in JavaScript's event loop, making it entirely independent of CSS animation rules. The DOM manipulation is direct: element.style.opacity = value.
Deployment and Cache Invalidation
The updated staging file was deployed to the S3 staging bucket:
s3://[staging-bucket]/staging-index.html
Since the staging site is served through CloudFront (distribution ID: [obtained from infrastructure docs]), we invalidated the cache immediately after deployment to ensure all edge locations served the updated file:
aws cloudfront create-invalidation --distribution-id [CF-DIST-ID] --paths "/*"
Without cache invalidation, browsers would have continued serving the old CSS animation from edge caches, potentially for hours.
Scope of Changes Across Event Subdomains
The same hero section pattern exists on multiple artist event pages. Files modified include:
/tmp/mariachiusa-staging.html/tmp/gipsykings-staging.html- Corresponding staging files for Bonnie Raitt, Buddy Guy, and other artist events
Each staging deployment included the same animation migration logic and required individual CloudFront invalidation.
Related Infrastructure Issues Addressed
During this session, several other issues surfaced:
- Inconsistent pricing across event pages: Some staging pages displayed correct prices while others showed outdated or incorrect values. This required syncing the Google Apps Script backend with updated pricing data from the events manifest.
- Missing artist images on select subdomains: Investigated and corrected image asset paths in staging pages.
- Zombie Playwright/Chromium processes: Automated testing left orphaned browser processes consuming system resources. Identified and terminated via process inspection.
Key Architectural Decisions
Why JavaScript instead of CSS animations: While CSS animations are performant for simple transitions, they're vulnerable to global CSS rules that disable all animations. JavaScript-driven opacity changes provide explicit control and resilience against accessibility overrides.
Why we keep the `prefers-reduced-motion` rule: We did not remove the accessibility override. Instead, we ensured critical UX elements (like this hero animation) have fallback implementations that respect user preferences while still providing engaging experiences.
Why immediate CloudFront invalidation matters: Staging sites are ephemeral—teams rely on them to verify changes before production deployment. Stale cache entries can mask bugs and create false confidence in broken implementations.
Testing and Verification
After deployment, we verified the animation across multiple browsers and devices:
- Desktop Chrome/Safari with "Reduce motion" enabled: Animation works (JavaScript-driven)
- Desktop Chrome/Safari with "Reduce motion" disabled: Animation works (JavaScript-driven)
- Mobile Safari (iOS): Animation works as before
- Mobile Chrome (Android): Animation works as before
The key improvement: the animation now functions regardless of system accessibility settings.
What's Next
This experience highlighted a broader pattern in our codebase: CSS animations are convenient but fragile when used for