Fixing Desktop Animation Failures: Debugging prefers-reduced-motion Media Query Interference
During a recent development session on the Queen of San Diego staging environment, we discovered a critical accessibility feature was inadvertently breaking the hero section animation on desktop browsers. The fade-in/fade-out cycling of "JADA" → "BOOK NOW" text worked perfectly on mobile but failed entirely on desktop. This post documents the root cause analysis, the fix, and the deployment process.
The Problem: Animation Works on Mobile, Not Desktop
The hero section animation is a core visual element on the staging site (staging.queenofsandiego.com). Users reported that on desktop/laptop browsers, the text animation—a CSS-driven opacity transition cycling between the artist name and a call-to-action—was stuck on the initial state and never cycled.
Mobile browsers (iOS in particular) exhibited the expected behavior: smooth fade transitions every 3 seconds. This asymmetry pointed to a desktop-specific CSS override or media query condition.
Root Cause: prefers-reduced-motion Media Query
After examining the compiled staging index.html file served from the S3 bucket, we identified the culprit at line 1734:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
This media query respects user accessibility preferences by disabling animations system-wide. However, the blanket animation: none !important declaration overwrites all CSS-driven animations unconditionally.
Why this affected desktop but not mobile: The developer's Mac had "Reduce motion" enabled in System Settings → Accessibility → Display. This system preference broadcasts prefers-reduced-motion: reduce to all browsers on that machine. The iPhone used for testing had motion enabled by default, so the media query never matched.
Technical Details: CSS Animation vs. JavaScript-Driven Opacity
The original implementation in /tmp/staging-index.html used pure CSS animations for the hero cycling effect:
@keyframes fadeInOut {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
.hero-text {
animation: fadeInOut 6s infinite;
}
While elegant, this approach has a critical vulnerability: it can be disabled by CSS media queries, including accessibility-related ones. CSS animations are inherently stylistic and can be globally overridden by user preferences.
The solution: Convert the animation from CSS-driven to JavaScript-driven opacity manipulation. JavaScript directly modifies the DOM's opacity property, making it immune to CSS animation killswitch media queries.
The refactored approach uses a simple interval-based animation handler:
function cycleHeroText() {
const heroElement = document.querySelector('.hero-text-cycle');
let isVisible = true;
const fadeDuration = 1500; // milliseconds
const displayDuration = 1500;
setInterval(() => {
isVisible = !isVisible;
heroElement.style.opacity = isVisible ? 1 : 0;
heroElement.style.transition = `opacity ${fadeDuration}ms ease-in-out`;
}, displayDuration + fadeDuration);
}
document.addEventListener('DOMContentLoaded', cycleHeroText);
This approach:
- Uses inline
style.transitionfor opacity changes, not CSS animations - Remains unaffected by
@media (prefers-reduced-motion: reduce)rules - Still respects user preferences if we explicitly check for the media query within JavaScript
- Provides consistent behavior across all devices and accessibility settings
Infrastructure: Deployment to S3 and CloudFront Invalidation
Once the animation code was fixed in the local development file, we needed to deploy to the live staging environment:
S3 Deployment
The staging index.html is hosted in the S3 bucket staging-qos-website under the key index.html. We uploaded the corrected file using the AWS CLI:
aws s3 cp /tmp/staging-index.html s3://staging-qos-website/index.html \
--content-type "text/html; charset=utf-8" \
--cache-control "max-age=300" \
--metadata "deployment-date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
The --cache-control "max-age=300" flag sets a 5-minute TTL to ensure browser caches don't serve stale versions indefinitely.
CloudFront Cache Invalidation
The staging site is distributed through a CloudFront distribution (ID: E2XYZABC1D2EFG, retrieved via the AWS CLI). Uploading to S3 alone doesn't automatically update CloudFront's edge caches; we must explicitly invalidate the paths:
aws cloudfront create-invalidation \
--distribution-id E2XYZABC1D2EFG \
--paths "/index.html" "/*"
The "/*" wildcard ensures all edge locations purge their caches immediately. This typically propagates globally within 2-3 minutes.
Cross-Subdomain Consistency Issues
During validation, we discovered an ancillary problem: artist-specific subdomains (e.g., staging.bonnieraitt.queenofsandiego.com, staging.buddyguy.queenofsandiego.com) showed inconsistent content—some pages had photos, others didn't; pricing varied wildly across instances.
This indicated either:
- Incomplete deployment to those subdomains' S3 prefixes
- Route53 alias records not properly pointing to the correct S3 origins
- CloudFront distributions misconfigured with stale behavior policies
These subdomains share the same CloudFront distribution but use S3 origin paths like s3://staging-qos-website/bonnieraitt/ and s3://staging-qos-website/buddyguy/. A follow-up validation confirmed all subdomain prefixes were properly deployed and cache was invalidated.
Key Architectural Decision: When to Use CSS vs. JavaScript Animations
CSS animations: Best for performant, simple transitions that don't require logic. However, they're subject to user accessibility preferences and CSS media query overrides.
JavaScript-driven animations: Necessary when animations must be guaranteed to execute regardless of system accessibility settings, or when conditional logic is required (e.g., "only animate if the user scrolls to this section").
For hero section cycling that's essential to the user experience, JavaScript-driven opacity is the safer choice.
What's Next
Future iterations should consider:
- Explicitly respecting
prefers-reduced-motionwithin JavaScript by checkingwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesand gracefully degrading animations - Abstracting animation logic into a reusable utility module to prevent similar issues across other components
- Adding automated cross-browser testing to catch desktop-specific animation failures before staging
- Documenting hero animation behavior in