Skip to main content
CodeRocket
Accessibilitymediumanimation

Provide alternatives to parallax effects

rule · parallax-effects

Parallax effects can cause severe discomfort for users with vestibular disorders.

Code Example

CSS
/* ❌ Bad: Always applies parallax */
.hero {
  background-attachment: fixed;
  background-position: center;
  transform: translateZ(-1px) scale(2);
}
 
/* ✅ Good: Only when motion is acceptable */
@media (prefers-reduced-motion: no-preference) {
  .hero {
    background-attachment: fixed;
  }
}
 
/* Reduced motion: static background (default) */
.hero {
  background-attachment: scroll;
}

Why It Matters

Parallax creates a mismatch between what the eyes see and what the inner ear senses—triggering dizziness, nausea, and vertigo for users with vestibular disorders.

JavaScript Parallax Control

TypeScript
class ParallaxController {
  private prefersReducedMotion: boolean
 
  constructor() {
    this.prefersReducedMotion = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    ).matches
 
    // Listen for preference changes
    window.matchMedia('(prefers-reduced-motion: reduce)')
      .addEventListener('change', (e) => {
        this.prefersReducedMotion = e.matches
        this.toggle(!e.matches)
      })
  }
 
  init(elements: HTMLElement[]) {
    if (this.prefersReducedMotion) return
 
    elements.forEach(el => this.applyParallax(el))
    window.addEventListener('scroll', () => this.update(elements))
  }
 
  private applyParallax(element: HTMLElement) {
    // Parallax logic here
  }
 
  toggle(enabled: boolean) {
    document.body.classList.toggle('parallax-disabled', !enabled)
  }
}

React Parallax Component

TSX
interface ParallaxProps {
  children: React.ReactNode
  speed?: number // 0 to 1, default 0.5
  className?: string
}
 
function Parallax({ children, speed = 0.5, className }: ParallaxProps) {
  const [offset, setOffset] = useState(0)
  const prefersReducedMotion = useReducedMotion()
  const ref = useRef<HTMLDivElement>(null)
 
  useEffect(() => {
    if (prefersReducedMotion) return
 
    const handleScroll = () => {
      if (ref.current) {
        const rect = ref.current.getBoundingClientRect()
        setOffset(rect.top * speed)
      }
    }
 
    window.addEventListener('scroll', handleScroll, { passive: true })
    return () => window.removeEventListener('scroll', handleScroll)
  }, [speed, prefersReducedMotion])
 
  return (
    <div
      ref={ref}
      className={className}
      style={{
        transform: prefersReducedMotion ? 'none' : `translateY(${offset}px)`
      }}
    >
      {children}
    </div>
  )
}
 
function useReducedMotion() {
  const [prefersReduced, setPrefersReduced] = useState(false)
 
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
    setPrefersReduced(mediaQuery.matches)
 
    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches)
    mediaQuery.addEventListener('change', handler)
    return () => mediaQuery.removeEventListener('change', handler)
  }, [])
 
  return prefersReduced
}

User Toggle Control

TSX
function MotionPreferences() {
  const [motionEnabled, setMotionEnabled] = useState(true)
 
  useEffect(() => {
    document.body.classList.toggle('reduce-motion', !motionEnabled)
  }, [motionEnabled])
 
  return (
    <label className="motion-toggle">
      <input
        type="checkbox"
        checked={motionEnabled}
        onChange={(e) => setMotionEnabled(e.target.checked)}
      />
      Enable motion effects
    </label>
  )
}
CSS
/* Global disable via user preference */
.reduce-motion * {
  animation-duration: 0.01ms !important;
  transition-duration: 0.01ms !important;
  transform: none !important;
}

Safe Static Fallback

CSS
/* Default: no parallax */
.hero-section {
  background-image: url('hero.jpg');
  background-size: cover;
  background-position: center;
}
 
/* Parallax only with explicit motion preference */
@media (prefers-reduced-motion: no-preference) {
  .hero-section {
    background-attachment: fixed;
  }
}

Content Must Work Without Effect

HTML
<!-- ❌ Bad: Parallax hides essential content -->
<section class="parallax-only">
  <div class="background-layer">Important info here</div>
</section>
 
<!-- ✅ Good: Content accessible regardless of effect -->
<section class="hero">
  <div class="hero-background" aria-hidden="true"></div>
  <div class="hero-content">
    <h1>Welcome</h1>
    <p>All content readable without parallax</p>
  </div>
</section>

Exceptions

  • Evaluate the rendered experience before treating a static-code smell as a blocker; interaction timing, browser behavior, and assistive technology output often determine severity.
  • Not every secondary accessibility issue deserves equal weight; prioritize the issue that most directly blocks perception, operation, or understanding.
  • Avoid adding redundant markup or ARIA solely to satisfy a rule when a simpler semantic implementation would eliminate the issue entirely.

Verification

Automated Checks

  • Use browser accessibility tooling, axe, Lighthouse, or equivalent automated checks against a representative rendered state.

Manual Checks

  • Enable "Reduce motion" in OS accessibility settings
  • Verify parallax effects are disabled
  • Confirm all content is still visible and readable
  • Check that page layout doesn't break without effects
  • Test user toggle if provided