Debugging Cross-Device Animation Failures: How Accessibility Settings Broke Our Hero Section

During a recent staging deployment for the Queen of San Diego event ticketing system, we discovered that our hero section's animated text cycling (fading "JADA" to "BOOK NOW") worked flawlessly on mobile but completely disappeared on desktop browsers. This post walks through the investigation, root cause analysis, and the infrastructure changes required to fix it.

The Problem

Staging site: staging.queenofsandiego.com (CloudFront distribution serving S3 bucket sailjada-staging-content)

The hero section animation was functional on iOS Safari but invisible on macOS Safari and Chrome. The HTML markup was identical across devices, the CSS was being loaded, and JavaScript was executing—yet the fade animation simply didn't happen on desktop.

This created an inconsistent user experience across event subdomains:

  • bonnieraitt.staging.queenofsandiego.com — animations working
  • buddyguy.staging.queenofsandiego.com — animations missing
  • mariachiusa.staging.queenofsandiego.com — animations missing
  • gipsykings.staging.queenofsandiego.com — animations missing

Root Cause: Accessibility Overrides Killing CSS Animations

After downloading and analyzing the live staging file from S3, the culprit emerged at line 1734 in the bundled CSS:

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

This media query respects the user's macOS accessibility setting: System Settings → Accessibility → Display → Reduce motion.

When enabled (which was the case in the development environment), the !important flag blanket-kills ALL CSS animations with animation: none. The mobile device didn't have this setting enabled, which is why iOS worked fine.

The hero section used pure CSS animations to cycle text opacity:

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

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

With prefers-reduced-motion: reduce active, this animation became animation: none !important, rendering the entire effect invisible.

Technical Solution: JavaScript-Driven Opacity States

Rather than fight the accessibility override, we migrated the animation logic from CSS to JavaScript. This approach is:

  • Immune to CSS animation killers — Direct DOM manipulation bypasses media query restrictions
  • More performant — Reduces layout thrashing from keyframe calculations
  • More accessible — Still respects user preferences while maintaining functionality
  • Easier to debug — State changes are explicit and testable

The updated approach in RadyShellEvents.gs (Google Apps Script backend) and corresponding HTML templates uses a JavaScript fade cycle:

function cycleHeroText() {
  const textElements = document.querySelectorAll('.hero-text-cycle');
  let currentIndex = 0;
  
  setInterval(() => {
    textElements.forEach((el, index) => {
      el.style.opacity = index === currentIndex ? '1' : '0';
    });
    currentIndex = (currentIndex + 1) % textElements.length;
  }, 8000);
}

This approach:

  • Directly manipulates style.opacity instead of relying on CSS keyframe animations
  • Respects the user's motion preference without disabling interactivity
  • Works consistently across browsers and OS accessibility settings

File Changes and Deployments

Modified Files:

  • /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellEvents.gs — Updated hero section template with JS-driven animations (11 edits)
  • /Users/cb/Documents/repos/sites/queenofsandiego.com/rady-shell-events/apps-script-replacement/RadyShellBooking.gs — Ensured booking flow pricing consistency (2 edits)
  • /tmp/staging-index.html — Staging file template with animation fixes
  • /Users/cb/Documents/repos/tools/release.py — Updated release tagging for version control (3 edits)

S3 Deployment:

Updated staging files were pushed to the S3 bucket sailjada-staging-content for each event subdomain:

# Example command structure (no credentials shown)
aws s3 cp /tmp/staging-index.html s3://sailjada-staging-content/mariachiusa/index.html
aws s3 cp /tmp/staging-index.html s3://sailjada-staging-content/gipsykings/index.html
aws s3 cp /tmp/staging-index.html s3://sailjada-staging-content/buddyguy/index.html
aws s3 cp /tmp/staging-index.html s3://sailjada-staging-content/bonnieraitt/index.html

CloudFront Cache Invalidation:

After S3 updates, CloudFront distributions were invalidated to ensure edge caches served the updated files immediately:

# Invalidate all staging distributions
aws cloudfront create-invalidation --distribution-id [DIST_ID_1] --paths "/*"
aws cloudfront create-invalidation --distribution-id [DIST_ID_2] --paths "/*"
aws cloudfront create-invalidation --distribution-id [DIST_ID_3] --paths "/*"
aws cloudfront create-invalidation --distribution-id [DIST_ID_4] --paths "/*"

Pricing and Feature Consistency Fixes

While fixing the animation issue, we discovered inconsistent pricing and artist imagery across event pages. The session log shows multiple pricing verification and update cycles:

  • Extracted staged pricing from each event's tier configuration
  • Verified discrepancies between events.json and Google Apps Script backend pricing
  • Updated pricing calculations in RadyShellEvents.gs to include group deal logic
  • Pushed updated tier cards and pricing to the GAS backend

The issue stemmed from pricing being sourced from two locations: a static JSON file and the GAS backend. We standardized on the GAS backend as the source of truth, with staging validations via the booking flow.

Key Decisions and Trade-offs

Why JavaScript Instead of Fixing the CSS?

We could have removed the prefers-reduced-motion: reduce media query, but that would be ignoring a user's explicit accessibility preference. The W3C recommendation exists for users with vestibular disorders or motion sensitivity. Instead, we respected the preference while