Debugging Cross-Device CSS Animation Failures: From prefers-reduced-motion to JavaScript-Driven State Management

The Problem: Hero Animation Works on Mobile, Breaks on Desktop

During a development session, we identified a critical UX regression on the staging environment for staging.queenofsandiego.com. The hero section's fade animation—cycling between the text "JADA" and "BOOK NOW"—functioned correctly on mobile devices but completely failed on desktop browsers. This wasn't a simple CSS mistake; it was a subtle accessibility feature interaction that deserves detailed explanation for future reference.

Root Cause Analysis: The prefers-reduced-motion Media Query Trap

The culprit was a blanket CSS rule applied via the prefers-reduced-motion: reduce media query at line 1734 in /tmp/staging-index.html. This media query, intended to respect user accessibility preferences, contained:

@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
  }
}

This rule disables all CSS animations site-wide when a user has enabled "Reduce motion" in their operating system accessibility settings. The critical detail: on the staging machine (macOS), the System Settings → Accessibility → Display had "Reduce motion" enabled, which triggered this rule on desktop browsers. Mobile devices accessing the same staging site had motion enabled, explaining the inconsistent behavior.

The fade in/fade out cycling was implemented purely as a CSS keyframe animation. When the media query matched, the animation property was overridden with animation: none !important, and the animation ceased entirely.

Technical Details: Animation Implementation Patterns

The original implementation used CSS keyframes for the hero text cycling:

@keyframes fadeInOut {
  0% { opacity: 0; }
  25% { opacity: 1; }
  75% { opacity: 1; }
  100% { opacity: 0; }
}

.hero-text {
  animation: fadeInOut 4s ease-in-out infinite;
}

This approach is performant and simple for straightforward animations. However, it has a fundamental vulnerability: CSS animations are global state that can be disabled by system-level preferences or media queries. The !important flag makes the override immune to specificity battles, but also renders the animation permanently disabled without JavaScript intervention.

Solution: JavaScript-Driven Opacity State Management

The fix converts the cycling logic from CSS animation to JavaScript-driven state management. Instead of relying on a keyframe animation that can be globally disabled, we implement the fade cycling using JavaScript's requestAnimationFrame and opacity DOM manipulation:

function initHeroTextCycle() {
  const heroElement = document.querySelector('.hero-text');
  let opacity = 0;
  let direction = 1; // 1 for increasing, -1 for decreasing
  let frameCount = 0;
  const cycleDuration = 240; // frames at 60fps = 4 seconds
  const holdFrames = 60; // frames to hold at full opacity

  function animate() {
    if (frameCount < holdFrames) {
      heroElement.style.opacity = '1';
    } else if (frameCount < holdFrames + 60) {
      // Fade out phase
      opacity = 1 - ((frameCount - holdFrames) / 60);
      heroElement.style.opacity = opacity;
    } else if (frameCount < holdFrames + 120) {
      // Fade in phase
      opacity = (frameCount - holdFrames - 60) / 60;
      heroElement.style.opacity = opacity;
    } else {
      frameCount = -1; // Reset cycle
    }

    frameCount++;
    requestAnimationFrame(animate);
  }

  animate();
}

// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', initHeroTextCycle);

This approach has several advantages:

  • Immune to CSS media queries: JavaScript-driven opacity changes cannot be overridden by prefers-reduced-motion media queries
  • Explicit control: We manage state directly, allowing for future enhancements like pause-on-interaction or conditional enabling
  • Accessibility-aware: If accessibility concerns arise, we can add a check for window.matchMedia('(prefers-reduced-motion: reduce)').matches and conditionally skip animation initialization
  • Performance: requestAnimationFrame is optimized by browsers and respects display refresh rates

Deployment and Cache Invalidation

The modified index.html file was deployed to the S3 staging bucket via the following workflow:

# Verify file modifications before deployment
aws s3 ls s3://staging-queenofsandiego-web/ --recursive

# Deploy the updated index.html to staging bucket
aws s3 cp /tmp/staging-index.html s3://staging-queenofsandiego-web/index.html

# Verify deployment
aws s3api head-object --bucket staging-queenofsandiego-web --key index.html

Critical: after deploying to S3, we invalidated the CloudFront distribution cache to ensure browsers and edge locations fetch the updated file:

# Invalidate CloudFront distribution to force cache refresh
aws cloudfront create-invalidation --distribution-id [DISTRIBUTION_ID] --paths "/index.html"

The CloudFront distribution ID was identified by querying the distribution list and matching it to the staging domain. This ensures that all edge locations serving staging.queenofsandiego.com immediately purge cached versions and fetch the new code.

Why This Matters: Accessibility vs. User Experience Trade-offs

The prefers-reduced-motion media query exists for valid accessibility reasons. Users with vestibular disorders or motion sensitivity benefit from reduced animations. However, applying animation: none !important globally is overly aggressive and can inadvertently disable animations that don't cause motion sickness—like simple opacity fades.

The JavaScript approach lets us make granular decisions: we can respect accessibility preferences where it matters (parallax scrolling, transforms) while maintaining subtle UX polish (opacity transitions) through alternative implementations.

Related Issues: Photo and Price Data Inconsistencies

Separately, the user noted inconsistencies across performer pages—some with photos and pricing, others without. This suggests either:

  • Incomplete migrations for pages like buddyguy.html, bonnieraitt.html, etc.
  • Conditional rendering based on data sources that haven't been updated consistently
  • Release versioning mismatches between static pages and dynamic data endpoints

These should be addressed through a separate audit of the releases manifest and data synchronization pipeline.

Key Takeaway for Future Development

When implementing user-facing animations, default to JavaScript-driven state for anything critical to UX, and reserve CSS animations for non-essential polish. Document why each approach was chosen. This prevents silent failures when CSS media queries or browser extensions interfere with your intended behavior.