Fixing Cross-Platform CSS Animation Failures: When prefers-reduced-motion Silently Breaks Your Hero Animations

During a recent staging deployment cycle for the Queen of San Diego events platform, we discovered that the hero section's fade-in/fade-out animation for the "JADA" → "BOOK NOW" text cycle was working flawlessly on mobile devices but completely absent on desktop browsers. This wasn't a JavaScript error or a deployment issue—it was a subtle CSS media query interaction that silently disabled all animations on systems with accessibility preferences enabled.

The Problem: CSS Animation Blanket Kills

Our hero section in /tmp/staging-index.html (served from S3 staging bucket) contained a CSS animation loop that cycled through hero text with opacity transitions:

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

This media query exists for accessibility reasons—systems with "Reduce motion" enabled in System Settings → Accessibility → Display trigger this rule globally. The !important flag ensures it overrides all animation declarations. However, our implementation suffered from overkill: we were blanket-killing ALL animations with a universal selector rather than targeting specific motion-heavy elements.

The asymmetry was striking: iOS devices (with motion enabled by default) rendered the cycling animation perfectly. But on macOS with the "Reduce motion" accessibility setting enabled, the entire animation chain silently stopped. The hero section still displayed, text still appeared, but the dynamic cycle was gone.

Why This Matters: Accessibility vs. Functionality

The prefers-reduced-motion media query is a legitimate accessibility feature designed to help users with vestibular disorders and motion sensitivity. However, the blanket animation: none !important approach conflates "reduce motion" with "disable all motion," which is overly aggressive.

A better approach: respect the preference while maintaining core functionality. For non-critical animations (like hero text cycling), we can either:

  • Convert CSS animations to JavaScript-driven opacity changes, which bypass the media query entirely
  • Keep CSS animations but remove the universal * selector—only target specific elements
  • Implement a more graceful degradation where animations become instant transitions instead of disappearing

We chose option #1: JavaScript-driven animation for the hero cycle. This preserves the visual effect while remaining exempt from CSS animation rules.

Technical Implementation: From CSS to JavaScript

The original CSS in /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs used keyframe animations:

@keyframes fadeInOut {
  0%, 100% { opacity: 0; }
  10%, 90% { opacity: 1; }
}

We replaced this with JavaScript that directly manipulates DOM element opacity and uses native setTimeout for timing control:

function cycleHeroText() {
  const heroElements = document.querySelectorAll('.hero-text-cycle');
  let currentIndex = 0;
  
  setInterval(() => {
    heroElements.forEach((el, idx) => {
      el.style.opacity = idx === currentIndex ? '1' : '0';
    });
    currentIndex = (currentIndex + 1) % heroElements.length;
  }, 5000); // 5-second cycle
}

This approach has several advantages:

  • CSS-independent: No media queries can override it
  • Explicit control: We control exact timing without relying on CSS keyframe parsing
  • Graceful degradation: If JavaScript fails, text still appears (just doesn't cycle)
  • Accessibility-aware: We can still check window.matchMedia('(prefers-reduced-motion: reduce)')` if we wanted to respect the preference for this particular animation

Deployment Pipeline: S3 → CloudFront Invalidation

Once the JavaScript was updated in the local source, we deployed through our standard pipeline:

# Upload updated staging file
aws s3 cp /tmp/staging-index.html s3://staging.queenofsandiego.com/index.html

# Invalidate CloudFront cache for immediate propagation
aws cloudfront create-invalidation \
  --distribution-id [STAGING_DISTRIBUTION_ID] \
  --paths "/*"

The CloudFront distribution ID (stored in our deployment config, not exposed here) ensures that all edge locations globally purge their cached version. Without this invalidation, users would see the old CSS-based animation (or lack thereof) for up to 24 hours due to TTL settings.

Multi-Subdomain Consistency Issue

While fixing the main hero animation, we discovered inconsistency across event subdomains. Several staging deployments for buddyguy.staging.queenofsandiego.com, bonnieraitt.staging.queenofsandiego.com, and others had:

  • Missing artist images (photo assets not uploaded to S3 bucket)
  • Mismatched pricing tiers (some events showing outdated tier pricing)
  • Partially deployed staging pages (HTML updated, images not synced)

We standardized this by:

  1. Extracting all event-to-CloudFront distribution mappings from Route53
  2. Downloading current staging HTML from each subdomain
  3. Verifying S3 bucket contents for artist images per event
  4. Re-uploading missing assets and invalidating each distribution
  5. Updating events.json pricing schema via Google Apps Script backend to push tier changes atomically

This revealed a process gap: our staging deployment script wasn't verifying asset completeness before marking a deployment as "done."

Key Decisions and Trade-offs

  • JavaScript over CSS animations: Traded slightly higher JavaScript execution cost for immunity to CSS media query overrides. Trade-off was worth it for core UI functionality.
  • Immediate CloudFront invalidation: We invalidate all paths rather than specific files to avoid edge cases where assets reference each other. Slightly more expensive (invalidation costs), but eliminates stale asset chains.
  • Google Apps Script for pricing: Rather than manually updating multiple staging pages, we centralized pricing in GAS backend, which each subdomain queries. Single source of truth reduces deployment risk.

What's Next

We're implementing automated pre-deployment verification that checks:

  • Asset completeness (all images referenced in HTML exist in S3)
  • CSS animation compatibility across accessibility profiles
  • Cross-browser rendering via automated screenshots
  • CloudFront cache hit rates to optimize invalidation strategy

This incident reinforced the importance of testing across accessibility profiles—not just device types.