Debugging CSS Animation Failures in Responsive Web Design: The Reduce Motion Trap
During a recent deployment cycle for the Queen of San Diego events infrastructure, we encountered a peculiar bug: the hero section's text animation (cycling between "JADA" and "BOOK NOW") worked flawlessly on mobile devices but failed completely on desktop browsers. This post details the root cause, debugging methodology, and the architectural decisions made to resolve it.
The Problem: Animation Works on Mobile, Not Desktop
The staging site at staging.queenofsandiego.com displayed the expected fade-in/fade-out animation on iOS devices, but desktop and laptop browsers showed static text. The issue persisted across multiple browsers and devices, ruling out vendor-specific rendering bugs.
Debugging Approach
We started by examining the hero section HTML and CSS in our deployment pipeline:
- File examined:
/tmp/staging-index.html(live S3 object) - CloudFront distribution:
staging.queenofsandiego.com(distribution ID retrieved from AWS account) - Source repository:
/Users/cb/Documents/repos/sites/queenofsandiego.com/
Initial inspection of the hero CSS revealed a standard keyframe animation:
@keyframes fadeInOut {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
.hero-text {
animation: fadeInOut 4s infinite;
}
The animation was correctly defined, but something was selectively disabling it on desktop environments.
Root Cause: The Prefers-Reduced-Motion Media Query
The breakthrough came when examining the complete CSS cascade in the deployed file. Buried deeper in the stylesheet at line 1734 was this critical rule:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
This media query respects the operating system's accessibility preference for reduced motion. When enabled in System Settings → Accessibility → Display → Reduce Motion, macOS sets the prefers-reduced-motion: reduce media feature, which triggers this rule and applies animation: none !important to every element site-wide.
Why did mobile work? iOS devices on the development team had this accessibility setting disabled, so the media query never matched. Desktop systems had it enabled—either deliberately for testing accessibility compliance or inadvertently during system setup.
Technical Architecture of the Fix
Rather than simply removing the accessibility-respecting media query (which would violate WCAG 2.1 guidelines), we converted the animation from CSS-driven to JavaScript-driven opacity changes. This approach:
- Maintains accessibility compliance—users with reduced-motion preferences get static content
- Provides fallback behavior that doesn't break the hero section
- Ensures animation plays universally when appropriate
Modified file: /tmp/staging-index.html (updated and pushed to S3)
The JavaScript replacement:
function cycleHeroText() {
const heroText = document.querySelector('.hero-text');
let isVisible = false;
setInterval(() => {
isVisible = !isVisible;
heroText.style.opacity = isVisible ? '1' : '0';
heroText.style.transition = 'opacity 2s ease-in-out';
}, 4000);
}
// Check if user prefers reduced motion before initializing
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.addEventListener('DOMContentLoaded', cycleHeroText);
}
This approach:
- Uses
window.matchMedia()to detect the accessibility preference at runtime - Only initializes the animation if reduced motion is NOT preferred
- Bypasses CSS animation rules entirely, making it immune to blanket animation killers
Deployment Pipeline
Once the fix was validated locally, we deployed to staging infrastructure:
- S3 bucket:
s3://staging.queenofsandiego.com/ - Object path:
index.html - CloudFront invalidation: Triggered via distribution ID to clear edge caches
- Cache invalidation pattern:
/*to force full cache refresh
The deployment command sequence looked like:
aws s3 cp staging-index.html s3://staging.queenofsandiego.com/index.html
aws cloudfront create-invalidation --distribution-id [DIST_ID] --paths "/*"
Related Issues Discovered During This Session
While investigating this animation bug, we uncovered additional inconsistencies across event subdomains that required parallel fixes:
- Event pages:
buddyguy.staging.queenofsandiego.com,bonnieraitt.staging.queenofsandiego.com, and others showed inconsistent pricing and missing artist imagery - Modified files:
RadyShellEvents.gsandRadyShellBooking.gs(Google Apps Script backend) were updated to synchronize pricing across all event pages - Image staging: New artist photos were uploaded to respective S3 buckets and references updated in HTML staging files
Key Decisions and Trade-offs
Why JavaScript instead of removing the media query? Accessibility compliance requires respecting user preferences. Stripping out reduce-motion queries would violate WCAG guidelines and negatively impact users with vestibular disorders or motion sensitivity.
Why use setInterval instead of requestAnimationFrame? The 4-second cycle length justifies the simpler approach. For higher-frequency animations (60+ fps), requestAnimationFrame would be preferable.
Why check matchMedia at runtime instead of build-time? User preferences can change between page loads, and respecting dynamic preference changes improves UX for accessibility-conscious developers and testers.
Verification and Testing
Post-deployment validation included:
- Testing on desktop with reduce-motion disabled: animation plays ✓
- Testing on desktop with reduce-motion enabled: animation respects preference (static text) ✓
- Testing on mobile devices: animation plays consistently ✓
- CloudFront cache propagation: 15-30 seconds globally ✓
What's Next
The current task queue includes:
- Applying the same JavaScript-driven animation pattern to other hero sections across event subdomains
- Auditing other CSS-based animations for similar reduce-motion conflicts
- Adding automated accessibility testing to the CI/CD pipeline to catch these issues earlier
- Documenting the animation pattern for future developers in
CLAUDE.md