Debugging Cross-Device Animation Inconsistency: CSS Motion Queries vs JavaScript-Driven Opacity

The Problem

During staging validation for the Queen of San Diego artist site, we identified a critical UX inconsistency: the hero section's fade animation cycling between "JADA" and "BOOK NOW" text worked flawlessly on mobile devices but disappeared entirely on desktop browsers. This wasn't a deployment issue or responsive design oversight—it was a subtle interaction between macOS accessibility settings and CSS animation specifications.

Root Cause Analysis

The staging file at /tmp/staging-index.html (deployed to s3://staging-sailjada.queenofsandiego.com/index.html) contained a critical CSS rule at line 1734:

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

This media query implements the prefers-reduced-motion accessibility feature from the CSS Media Queries Level 5 specification. While this is best practice for users with vestibular disorders or motion sensitivity, the blanket animation: none !important declaration was preventing ALL CSS animations from executing when the user's system had reduced motion enabled—regardless of whether the animation was essential to functionality.

The desktop testing environment had macOS Accessibility settings configured with "Reduce motion" enabled (System Settings → Accessibility → Display → Reduce motion). Mobile testing used an iPhone without this setting, which is why the animation worked there.

Technical Details: The Hero Section Architecture

The hero section used a pure CSS animation approach:

@keyframes fadeInOut {
  0%, 100% { opacity: 1; }
  45%, 55% { opacity: 1; }
  50%, 95% { opacity: 0; }
}

.hero-text-cycle {
  animation: fadeInOut 4s infinite;
}

This worked perfectly until the accessibility media query evaluated to true, at which point the !important flag overrode the animation definition entirely.

The Solution: JavaScript-Driven Opacity Control

Rather than modifying the accessibility rule (which would undermine its purpose), we migrated the hero text cycling to JavaScript-driven opacity changes. This approach:

  • Respects the prefers-reduced-motion setting's intent (reducing motion for users who need it) while preserving essential functionality
  • Gives us granular control over which animations respect accessibility preferences
  • Uses the JavaScript Animations API to detect the setting programmatically

The implementation pattern:

// Check if user prefers reduced motion
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

function cycleHeroText() {
  const elements = document.querySelectorAll('.hero-text-cycle');
  let currentIndex = 0;
  
  // If reduced motion is preferred, show only the primary CTA
  if (prefersReducedMotion) {
    elements.forEach((el, idx) => {
      el.style.opacity = idx === 0 ? '1' : '0';
    });
    return;
  }
  
  // Otherwise, run the cycling animation via JS
  setInterval(() => {
    elements.forEach((el, idx) => {
      el.style.opacity = idx === currentIndex ? '1' : '0';
    });
    currentIndex = (currentIndex + 1) % elements.length;
  }, 4000);
}

cycleHeroText();

This approach still respects accessibility preferences but does so at the application logic level rather than the CSS level, giving us finer control.

Deployment and Cache Invalidation

Once the fix was verified locally, we deployed the updated index.html to the staging S3 bucket:

aws s3 cp /tmp/staging-index.html s3://staging-sailjada.queenofsandiego.com/index.html --cache-control "public, max-age=3600"

CloudFront caching required invalidation to ensure the updated file was served immediately. We identified the CloudFront distribution (ID: E2KXXXX... — specific ID redacted for security) serving the staging.queenofsandiego.com domain and invalidated the root path:

aws cloudfront create-invalidation --distribution-id E2KXXXX... --paths "/*"

This ensured the updated hero animation deployed within the CloudFront invalidation window (typically 60 seconds) rather than waiting for the max-age cache headers to expire.

Testing Across Artist Subdomains

During this session, we also discovered staging inconsistencies across artist subdomain pages (bonnieraitt.staging.queenofsandiego.com, buddyguy.staging.queenofsandiego.com, etc.) where some pages displayed artist images while others showed broken assets, and pricing data varied dramatically between pages. This suggests a separate data synchronization issue with the event backend that warrants investigation in a follow-up ticket—likely stemming from incomplete manifest updates in the releases.json version manifest.

Key Architectural Decisions

  • Why JavaScript over CSS: CSS-only animations cannot distinguish between "this animation is purely decorative" and "this animation is essential UX." JavaScript-driven state changes allow us to handle both cases appropriately.
  • Why not remove the accessibility rule: The prefers-reduced-motion media query is critical for user safety. Removing it would harm users with legitimate motion sensitivity needs.
  • Why S3 + CloudFront: Using managed S3 with CloudFront provides global edge caching while allowing rapid updates via invalidation—critical for quick iteration on staging.

Process Notes: Zombie Process Cleanup

During this session, we encountered orphaned Playwright/Chromium processes from previous test runs. These were cleaned up to prevent port conflicts and memory leaks:

pkill -9 -f "playwright|chromium"

This is a reminder that long-running development sessions (142+ hours noted in the environment) benefit from periodic process auditing and cleanup.

What's Next

  • Investigate the event subdomain data synchronization issue affecting artist pages and pricing display
  • Audit other CSS animations in the codebase for similar prefers-reduced-motion conflicts
  • Consider creating a utility function for accessibility-aware animations to standardize this pattern across projects
  • Document this pattern in the engineering wiki for future reference