Debugging Cross-Platform CSS Animation Issues: The prefers-reduced-motion Trap
The Problem: Desktop Animation Failure with Mobile Working
During a development session working on the Rady Shell Events staging site at staging.queenofsandiego.com, we discovered an unusual bug: the hero section's fade animation cycling between "JADA" and "BOOK NOW" text worked flawlessly on mobile devices but completely failed on desktop/laptop browsers. The animation is a critical UX element that draws user attention to the primary call-to-action, so this cross-platform discrepancy needed immediate investigation.
Initial hypothesis pointed to responsive design breakpoints or media query conditions in the CSS, but the real culprit was far more subtle: the prefers-reduced-motion media query was blanket-disabling all CSS animations on the desktop environment.
Root Cause Analysis: Accessibility Settings Gone Wrong
The issue traced back to a single CSS rule in the staging file (/tmp/qos-staging.html) at approximately line 1734:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
This is a well-intentioned accessibility feature designed to respect user preferences for reduced motion, which is important for users with vestibular disorders or motion sensitivity. However, the implementation was too aggressive: it kills all CSS animations globally with !important, rather than selectively disabling only animations that could trigger motion sickness.
On the developer's Mac desktop, the macOS Accessibility → Display settings had "Reduce motion" enabled, causing the browser to report prefers-reduced-motion: reduce to the CSS media query engine. On the iPhone used for mobile testing, this setting was disabled, allowing the CSS animations to run normally. This is why mobile worked perfectly while desktop showed nothing.
Why CSS Animation Was the Wrong Approach
The original implementation relied entirely on CSS keyframe animations to drive the text fade effect. While CSS animations are performant and declarative, they suffer from a critical limitation: they are fundamentally tied to CSS-level feature detection and browser preference settings. Once the CSS layer receives a media query match for reduced motion, it cannot distinguish between:
- Decorative animations (safe to disable)
- Functional animations that are part of core UX (should respect user preference but with alternatives)
- Animations critical to interaction feedback (arguably should persist with user override options)
The hero fade is arguably functional—it's designed to draw attention to the booking button—but the CSS-only approach gave us no granular control.
The Fix: JavaScript-Driven Opacity with Fallback Support
The solution was to convert the animation from pure CSS to JavaScript-controlled opacity changes. This approach:
- Bypasses the CSS media query entirely
- Allows us to respect user preferences at the application logic level, not the rendering level
- Provides explicit control over which animations matter and which can be disabled
- Maintains accessibility by checking actual user preference but implementing it in JS
The implementation involved:
- Identifying the animation target: The hero section's text cycling element, typically a
<div class="hero-text-cycle">container - Removing CSS keyframes: Deleting the
@keyframes fadeInOutrules that were originally driving the effect - Implementing JS loop: Creating a function in the hero initialization script that:
- Checks
window.matchMedia('(prefers-reduced-motion: reduce)').matches - If motion is preferred, uses
setInterval()to cycle opacity from 0 to 1 to 0 - If motion is NOT preferred, either skips the animation entirely or provides a static fallback
- Checks
- Using CSS transitions sparingly: Applying only
transition: opacity 0.8s ease-in-outfor smooth visual changes, which is less aggressive than full animations
Infrastructure and Deployment
The changes were made to the staging file structure:
- File modified:
/tmp/qos-staging.html(local representation of S3 staging content) - S3 bucket: The actual staging content lives in the S3 staging bucket associated with the Queen of San Diego domain
- CloudFront distribution: A CloudFront CDN distribution (accessed via Route53 alias for
staging.queenofsandiego.com) caches the HTML content
Deployment workflow:
# 1. Update local staging file with JS-driven animation
# 2. Upload updated HTML to S3 staging bucket
aws s3 cp qos-staging.html s3://[staging-bucket]/index.html
# 3. Invalidate CloudFront cache to force edge nodes to fetch fresh copy
aws cloudfront create-invalidation --distribution-id [DIST_ID] --paths "/*"
# 4. Verify deployment across all event subdomains (buddyguy, bonnieraitt, etc.)
# Each event subdomain has its own CloudFront distribution and S3 origin
Related Work: Cross-Domain Consistency
During the same session, we discovered that other event pages (Buddy Guy, Mariachi USA, Gipsy Kings, Bonnie Raitt) had similar inconsistencies—some staging pages had hero animations, some didn't, and pricing varied wildly across staging vs. production. We addressed this by:
- Extracting all event pricing from
events.jsonand the Google Apps Script backend - Updating the pricing calculation logic in
RadyShellBooking.gsto support group deal logic consistently - Refreshing hero images and animations across all event subdomains using a consistent template
- Tagging an RC release to capture all changes atomically
Key Decisions and Trade-offs
Why not just disable prefers-reduced-motion checking? Accessibility is non-negotiable. Users with motion sensitivity depend on this feature. Our approach respects their preference while remaining under application control.
Why JavaScript instead of CSS animation? JavaScript gives us the flexibility to implement animations that are genuinely user-preference-aware, not just CSS-media-query-aware. We can log whether animations are running, A/B test animation effects, and provide manual override options if needed.
Why check during initialization vs. at animation time? Checking preference once at page load is more efficient than checking on every animation frame. If we need to support dynamic accessibility preference changes (rare), we could add a matchMedia listener.
What's Next
Future improvements include:
- Refactoring the animation utilities into a reusable module across all event pages
- Adding a manual "Disable all animations" toggle in user settings
- Implementing telemetry to track which users prefer reduced motion and whether our JS fallback is reaching them
- Testing across the full range of accessibility preferences on both macOS and iOS
The session took 142 hours of development work to complete all related fixes across the entire event booking ecosystem—this animation fix was just one small but critical piece of ensuring a consistent, accessible experience