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.transition for 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-motion within JavaScript by checking window.matchMedia('(prefers-reduced-motion: reduce)').matches and 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