Debugging CSS Animation Breakage: When prefers-reduced-motion Kills Your Hero Section
During a staging deployment for the Queen of San Diego event ticketing platform, we discovered that the hero section's animated text cycling (JADA → BOOK NOW fade transition) worked flawlessly on mobile devices but failed completely on desktop browsers. What followed was a deep dive into accessibility settings, CSS animation cascading, and the decision to shift from CSS-driven to JavaScript-driven animation logic.
The Problem: Inconsistent Animation Behavior
The staging site at staging.queenofsandiego.com was serving updated hero animation code via /tmp/staging-index.html, which gets synced to the S3 bucket and distributed through CloudFront. The animation worked perfectly on iPhone Safari but disappeared entirely on desktop Safari and Chrome. This wasn't a code logic issue—the JavaScript was executing correctly, but the CSS animations weren't rendering.
Root Cause Analysis
After examining the deployed HTML served from S3 (production bucket for staging content), we found the culprit at line 1734 of the generated markup:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
This CSS rule is an accessibility best practice—it respects user system preferences for reduced motion. However, on the development machine, macOS accessibility settings had "Reduce motion" enabled in System Settings → Accessibility → Display. This setting triggers the prefers-reduced-motion: reduce media query, which then applies animation: none !important globally, overriding all CSS animations regardless of specificity.
Mobile devices (iPhone in this case) had motion preferences set to allow animations, so the media query didn't trigger and the CSS animations rendered normally.
Technical Architecture of the Animation System
The original implementation relied on pure CSS animations:
@keyframes fadeInOut {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
.hero-text {
animation: fadeInOut 3s infinite;
}
This approach is elegant and performant—the browser's rendering engine handles the animation entirely. However, it has a critical vulnerability: CSS animation rules can be globally disabled by accessibility settings or media query conditions. The `!important` flag makes it impossible to override at the component level.
The decision to migrate to JavaScript-driven opacity changes was made for resilience:
- Accessibility Bypass: JavaScript can explicitly check
window.matchMedia('(prefers-reduced-motion: reduce)').matchesand provide appropriate behavior (skip animations or use instant transitions) rather than failing silently - Explicit Control: The animation logic becomes visible and testable in the codebase
- Graceful Degradation: We can detect the user's preference and decide whether to animate or not, rather than having it forced off
- Performance Consistency: JavaScript-driven animations using
requestAnimationFrameprovide better debugging and profiling capabilities
Implementation Details
The updated code in /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs (modified 11 times during this session) now includes hero animation logic that respects user preferences:
function cycleHeroText() {
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const heroElement = document.querySelector('.hero-text-cycle');
if (!heroElement) return;
let opacity = 0;
const targetOpacity = 1;
const duration = prefersReduced ? 0 : 300; // Skip animation if reduced motion
const startTime = performance.now();
function updateOpacity(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
heroElement.style.opacity = prefersReduced ? targetOpacity : progress;
if (progress < 1) {
requestAnimationFrame(updateOpacity);
}
}
requestAnimationFrame(updateOpacity);
}
This approach checks the system preference upfront and either animates smoothly or applies changes instantly, depending on the user's accessibility settings.
Deployment Pipeline
After code changes were committed to the local repository at /Users/cb/Documents/repos/sites/queenofsandiego.com/, the deployment process was:
- Stage to S3: Updated HTML files were uploaded to the staging S3 bucket via the standard deployment tool (
release.pyin/Users/cb/Documents/repos/tools/) - Invalidate CloudFront: Cache invalidation was issued to the CloudFront distribution ID for staging.queenofsandiego.com
- Propagation: Edge caches across the CloudFront network were cleared within seconds, ensuring all requests received fresh content
The staging deployment targets all 9 event subdomains (Brandi Carlile, Bonnie Raitt, Buddy Guy, Gipsy Kings, Mariachi USA, etc.), each with their own CloudFront distribution but sharing the same origin S3 bucket.
Verification and Testing
After deployment, verification involved:
- Checking CloudFront distribution metadata and cache behaviors
- Downloading live staging files to compare against local source
- Testing on both mobile (iPhone) and desktop with different accessibility settings
- Confirming that the animation works when "Reduce motion" is disabled in system preferences
The release manifest was tagged to track this fix across the infrastructure, allowing for easy rollback if needed.
Key Decisions and Trade-offs
Why Not Just Disable Reduced Motion? We could have removed the @media (prefers-reduced-motion: reduce) rule entirely, but this would violate WCAG accessibility guidelines and disable animations for users who genuinely need them for health reasons (vestibular disorders, etc.).
Why requestAnimationFrame Instead of setTimeout? requestAnimationFrame syncs with the browser's repaint cycle, preventing janky animations and reducing unnecessary repaints. This is especially important on mobile devices with limited GPU resources.
What's Next
Subsequent changes involved updating pricing tiers across all event subdomains and ensuring consistent imagery and tier card layouts. The hero animation fix was foundational to ensure the user experience was consistent across all devices and accessibility configurations.
The lesson: always test with system-level accessibility settings enabled, not just at the application level. What works in one environment may fail in another due to cascading preference systems that are invisible at the code level.