Skip to content

DOMAIN:CREATIVE:MOTION_ACCESSIBILITY

OWNER: ilian (Motion Designer) UPDATED: 2026-03-28 SCOPE: Accessible motion design — prefers-reduced-motion, WCAG animation requirements, vestibular disorders, reduced motion alternatives ALSO_USED_BY: julian (Accessibility Lead — compliance enforcement), floris (Team Alfa frontend — CSS implementation), floor (Team Bravo frontend — CSS implementation), martijn (Team Alfa iOS — SwiftUI), valentin (Team Bravo iOS — SwiftUI), felice (Visual Asset Producer — video accessibility) ALSO_CHECK: domains/frontend/accessibility.md (general web accessibility, WCAG overview), domains/creative/motion-design-principles.md (core motion theory), domains/creative/ui-animation.md (motion tokens, performance) LEGAL_CONTEXT: European Accessibility Act (EAA) enforced since June 2025. Motion accessibility is NOT optional. Fines up to EUR 500,000.


WHY_MOTION_ACCESSIBILITY_MATTERS

FACT: Approximately 35% of adults over 40 experience vestibular dysfunction at some point. FACT: Motion sensitivity affects an estimated 5-10% of the general population to a degree that impacts daily device use. FACT: Even users without diagnosed conditions may experience discomfort from aggressive motion on small screens (car, bus, bed). RESULT: Ignoring motion accessibility excludes a significant portion of users AND violates EU law.

VESTIBULAR_DISORDERS

CONDITION: Vestibular system (inner ear + brain) processes spatial orientation and balance. TRIGGER: On-screen motion can create a mismatch between visual input and physical sensation, causing: - Dizziness and vertigo - Nausea - Headaches / migraines - Disorientation - In severe cases: inability to use the device for hours

MOST_PROBLEMATIC_MOTION_TYPES: 1. Parallax scrolling (background moves at different rate than foreground) 2. Large-scale zooming animations (full-page zoom transitions) 3. Spinning or rotating elements 4. Auto-playing animation that cannot be paused 5. Rapid scale changes (element growing from 0 to full size quickly) 6. Horizontal sliding page transitions (simulates physical movement) 7. Bouncing/oscillating animations (spring with low damping)

LESS_PROBLEMATIC: 1. Opacity fades (no spatial motion, generally safe) 2. Color transitions (no spatial motion) 3. Small-scale transforms (subtle scale 0.95-1.05) 4. Outline/border animations (no spatial displacement)


WCAG_2.1_REQUIREMENTS

SC_2.3.1 — THREE_FLASHES_OR_BELOW_THRESHOLD (Level A)

RULE: Content must not flash more than 3 times per second. DEFINITION: A flash is a pair of opposing changes in luminance (light-dark-light) that is large enough and in the right frequency. THRESHOLD: The combined area of flashes occurring concurrently occupies no more than 25% of any 341x256 pixel rectangle at the content's resolution. APPLIES_TO: Video, animation, GIF, CSS animation, canvas, WebGL. TEST: Use PEAT (Photosensitive Epilepsy Analysis Tool) or Harding FPA. RISK: SAFETY CRITICAL. Can trigger seizures in photosensitive epilepsy. This is not a UX preference — it is a medical safety requirement.

/* NEVER: rapid color alternation */
@keyframes danger-flash {
  0%   { background: white; }
  10%  { background: black; }
  20%  { background: white; }
  30%  { background: black; }  /* 3+ flashes in <1 second — VIOLATION */
}

SC_2.2.2 — PAUSE_STOP_HIDE (Level A)

RULE: For any auto-playing content that (a) starts automatically, (b) lasts more than 5 seconds, and (c) appears alongside other content — the user must be able to pause, stop, or hide it. APPLIES_TO: Background video, animated illustrations, carousels, auto-scrolling tickers, loading animations that persist. IMPLEMENTATION: Provide a visible pause/stop button. Not buried in settings — visible alongside the content.

// GOOD: auto-playing background video with visible pause control
function HeroVideo() {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [playing, setPlaying] = useState(true);

  function togglePlay() {
    if (videoRef.current) {
      playing ? videoRef.current.pause() : videoRef.current.play();
      setPlaying(!playing);
    }
  }

  return (
    <div className="relative">
      <video ref={videoRef} autoPlay muted loop playsInline>
        <source src="/hero.mp4" type="video/mp4" />
      </video>
      <button
        onClick={togglePlay}
        aria-label={playing ? "Pause background video" : "Play background video"}
        className="absolute bottom-4 right-4 rounded-full bg-black/50 p-2"
      >
        {playing ? <PauseIcon /> : <PlayIcon />}
      </button>
    </div>
  );
}

SC_2.3.3 — ANIMATION_FROM_INTERACTIONS (Level AAA)

RULE: Motion animation triggered by interaction can be disabled, unless the animation is essential to the functionality or information being conveyed. NOTE: This is Level AAA (not required for GE's baseline AA target), BUT GE implements it as best practice because it directly affects vestibular disorder users. IMPLEMENTATION: Respect prefers-reduced-motion. Provide a UI toggle as fallback.


PREFERS_REDUCED_MOTION

OVERVIEW

WHAT: A CSS media query that detects whether the user has requested reduced motion at the OS level. WHERE_SET: - macOS: System Settings → Accessibility → Display → Reduce motion - iOS: Settings → Accessibility → Motion → Reduce Motion - Windows: Settings → Accessibility → Visual effects → Animation effects (off) - Android: Settings → Accessibility → Remove animations - Linux/GNOME: Settings → Accessibility → Seeing → Reduce Animation

CSS_IMPLEMENTATION

/* APPROACH 1: Motion-first, reduce for preference (traditional) */
.element {
  transition: transform 300ms var(--motion-ease-out);
}

@media (prefers-reduced-motion: reduce) {
  .element {
    transition: none;
    /* OR: transition to opacity-only fallback */
    transition: opacity 200ms var(--motion-ease-out);
  }
}

/* APPROACH 2: No-motion-first, add for preference (progressive enhancement) */
/* RECOMMENDED — ensures motion is always opt-in */
.element {
  /* No transition by default */
}

@media (prefers-reduced-motion: no-preference) {
  .element {
    transition: transform 300ms var(--motion-ease-out);
  }
}

RECOMMENDATION: Use Approach 2 (no-motion-first) for new projects. Ensures that if the media query is not supported, the user gets no animation (safe default).

TAILWIND_CSS

// Tailwind motion-safe / motion-reduce variants
<div className="motion-safe:animate-bounce motion-reduce:animate-none">
  Content
</div>

// Transition only when user has no motion preference
<div className="motion-safe:transition-transform motion-safe:duration-300 hover:motion-safe:scale-105">
  Card
</div>

RULE: Every animate-* or transition-* class in Tailwind MUST have a motion-reduce: counterpart.

FRAMER_MOTION

// Global reduced motion support
import { MotionConfig } from "framer-motion";

function App({ children }) {
  return (
    <MotionConfig reducedMotion="user">
      {children}
    </MotionConfig>
  );
}

reducedMotion values: - "user" — respects OS preference (RECOMMENDED) - "always" — forces reduced motion (useful for testing) - "never" — ignores preference (NEVER use in production)

WITH reducedMotion="user": - animate still applies (final state is reached) - transitions are instant (duration effectively 0) - spring physics are disabled - layout animations still run (they are functional, not decorative)

OVERRIDE_FOR_SPECIFIC_ELEMENTS:

// Some animations are essential (e.g., loading spinner)
// Use motion.div with explicit transition that ignores reduced motion
<motion.div
  animate={{ rotate: 360 }}
  transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
  // This still runs with reduced motion because it's functional
/>

SWIFTUI

// Read system reduced motion preference
@Environment(\.accessibilityReduceMotion) var reduceMotion

// Conditional animation
var body: some View {
  Rectangle()
    .offset(x: isExpanded ? 200 : 0)
    .animation(reduceMotion ? .none : .spring(response: 0.35, dampingFraction: 0.7), value: isExpanded)
}

// Alternative: opacity crossfade instead of spatial motion
var body: some View {
  if reduceMotion {
    content
      .opacity(isVisible ? 1 : 0)
      .animation(.easeInOut(duration: 0.2), value: isVisible)
  } else {
    content
      .offset(y: isVisible ? 0 : 20)
      .opacity(isVisible ? 1 : 0)
      .animation(.spring(response: 0.35, dampingFraction: 0.7), value: isVisible)
  }
}

ANDROID_COMPOSE

// Read system animation scale
val reduceMotion = LocalContext.current
    .contentResolver
    .let { Settings.Global.getFloat(it, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) } == 0f

// Conditional animation spec
val animSpec = if (reduceMotion) {
    snap<Dp>()  // Instant, no animation
} else {
    spring(dampingRatio = 0.7f, stiffness = 300f)
}

val offset by animateDpAsState(
    targetValue = if (expanded) 200.dp else 0.dp,
    animationSpec = animSpec
)

JAVASCRIPT_DETECTION

// Hook for detecting reduced motion preference
function usePrefersReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
    setPrefersReducedMotion(mql.matches);

    function handleChange(event: MediaQueryListEvent) {
      setPrefersReducedMotion(event.matches);
    }

    mql.addEventListener("change", handleChange);
    return () => mql.removeEventListener("change", handleChange);
  }, []);

  return prefersReducedMotion;
}

RULE: Listen for changes (addEventListener). Users can toggle reduced motion while the app is open. RULE: Initial state should default to false (motion enabled) for SSR, then hydrate with actual preference.


REDUCED_MOTION_ALTERNATIVES

STRATEGY

PRINCIPLE: Reduced motion does NOT mean no visual feedback. It means no spatial/vestibular-triggering motion.

Full Motion Reduced Motion Alternative
Slide in from edge Opacity crossfade (150-200ms)
Bounce/spring entrance Instant appear or subtle scale (0.95 → 1.0)
Parallax scroll Static positioning, no scroll-linked motion
Page slide transition Opacity crossfade or instant cut
Expand/collapse with height animation Instant show/hide or opacity fade
Shared element morph Crossfade between views
Staggered list entrance All items appear simultaneously with subtle fade
Rotating spinner Static loading indicator or pulsing opacity
Confetti/particle celebration Static success icon + checkmark
Auto-scrolling carousel Manual-only carousel, no auto-advance
Zoom transition Opacity crossfade
Horizontal swipe transition Opacity crossfade

WHAT_STAYS_THE_SAME

EVEN_WITH_REDUCED_MOTION: - Hover state changes (color, outline) — no spatial motion - Focus ring appearance — essential for accessibility - Color transitions — safe, no spatial component - Opacity changes (fast, <200ms) — generally safe - Content loading/skeleton screens — functional, not decorative - Video playback (user-initiated) — user chose to play it - Progress bars — functional information

WHAT_MUST_CHANGE

MUST_BE_DISABLED_OR_REPLACED: - Parallax (any form) - Auto-playing animation - Decorative background animation - Scroll-triggered animations (scroll-linked effects) - Large-scale zoom transitions - Bouncing/oscillating motion - Rotating/spinning decorative elements - Sliding page transitions


IN_APP_MOTION_TOGGLE

WHY_NEEDED

REASON: Not all users know how to change OS-level accessibility settings. Some users want reduced motion only in one app. Some browsers/OS combinations have poor prefers-reduced-motion support. RULE: GE client projects SHOULD provide an in-app motion toggle in addition to respecting OS preference.

IMPLEMENTATION

// Context provider for motion preference
const MotionContext = createContext<{
  reducedMotion: boolean;
  setReducedMotion: (value: boolean) => void;
}>({ reducedMotion: false, setReducedMotion: () => {} });

function MotionProvider({ children }) {
  // Start with OS preference, allow override
  const osPreference = usePrefersReducedMotion();
  const [userOverride, setUserOverride] = useState<boolean | null>(null);

  const reducedMotion = userOverride ?? osPreference;

  return (
    <MotionContext.Provider value={{ reducedMotion, setReducedMotion: setUserOverride }}>
      <MotionConfig reducedMotion={reducedMotion ? "always" : "never"}>
        {children}
      </MotionConfig>
    </MotionContext.Provider>
  );
}

// Toggle in settings
function MotionToggle() {
  const { reducedMotion, setReducedMotion } = useContext(MotionContext);

  return (
    <label className="flex items-center gap-2">
      <input
        type="checkbox"
        checked={reducedMotion}
        onChange={(e) => setReducedMotion(e.target.checked)}
        role="switch"
        aria-checked={reducedMotion}
      />
      Reduce motion
    </label>
  );
}

RULE: Persist the user's choice in localStorage. Do not ask every session. RULE: OS preference is the default. In-app toggle overrides OS preference.


TESTING_MOTION_ACCESSIBILITY

MANUAL_TESTING_CHECKLIST

FOR_EVERY_PAGE: - [ ] Enable "Reduce motion" in OS settings - [ ] Reload the page — no motion should play on load (except essential functional animation) - [ ] Navigate between pages — transitions should be instant or crossfade only - [ ] Interact with all animated elements — feedback should be non-spatial (opacity, color, outline) - [ ] Check for auto-playing content — must have visible pause button or be disabled entirely - [ ] Verify no parallax effects remain - [ ] Check loading states — skeleton screens or static indicators, not animated spinners - [ ] Confirm the experience is EQUIVALENT — user can accomplish the same tasks, see the same information

AUTOMATED_TESTING

// Playwright test for reduced motion
import { test, expect } from "@playwright/test";

test.describe("reduced motion", () => {
  test.use({
    // Emulate reduced motion preference
    reducedMotion: "reduce",
  });

  test("page transitions are instant", async ({ page }) => {
    await page.goto("/");
    await page.click('a[href="/about"]');

    // No transition animation — page should be immediately visible
    const content = page.locator("main");
    await expect(content).toBeVisible();

    // Check that no transform animation is active
    const transform = await content.evaluate(
      (el) => getComputedStyle(el).transform
    );
    expect(transform).toBe("none");
  });

  test("no auto-playing animations", async ({ page }) => {
    await page.goto("/");

    // Check that no elements have running animations
    const animatingElements = await page.evaluate(() => {
      const all = document.querySelectorAll("*");
      return Array.from(all).filter((el) => {
        const style = getComputedStyle(el);
        return (
          style.animationName !== "none" &&
          style.animationPlayState === "running"
        );
      }).length;
    });

    // Allow essential animations (e.g., loading spinner if loading)
    // but no decorative animations
    expect(animatingElements).toBeLessThanOrEqual(1);
  });
});
// Playwright test for flash detection (simplified heuristic)
test("no rapid flashing content", async ({ page }) => {
  await page.goto("/");

  // Take 10 screenshots over 1 second
  const screenshots: Buffer[] = [];
  for (let i = 0; i < 10; i++) {
    screenshots.push(await page.screenshot());
    await page.waitForTimeout(100);
  }

  // For production: use PEAT or Harding FPA tool on actual video content
  // This is a smoke test only — real flash detection requires specialized tools
});

TESTING_TOOLS

Tool What It Tests Platform
PEAT (Photosensitive Epilepsy Analysis Tool) Flash frequency in video/animation Windows desktop
Harding FPA Broadcast-grade flash analysis Windows desktop
Chrome DevTools → Rendering → "Emulate prefers-reduced-motion" Quick toggle for manual testing Chrome
Firefox → about:config → ui.prefersReducedMotion Override OS setting Firefox
Playwright reducedMotion: "reduce" Automated testing CI/CD
axe-core General accessibility (not motion-specific) Any
Accessibility Insights Guided manual assessment including motion Chrome extension

CHROME_DEVTOOLS_EMULATION

1. Open DevTools (F12)
2. Cmd+Shift+P (Command Palette)
3. Type "Emulate CSS prefers-reduced-motion"
4. Select "reduce"
5. Page now behaves as if OS preference is reduce

RULE: Test with reduced motion emulation DURING development, not as an afterthought.


DECISION_TREE

Is this animation ESSENTIAL for understanding the content?
├── YES → Keep it, but ensure it does not flash >3/sec
│         and provide text alternative if it conveys information
└── NO → Does it trigger spatial/vestibular response?
    ├── YES (slide, zoom, parallax, bounce, rotate)
    │   └── MUST be disabled or replaced with crossfade under prefers-reduced-motion
    └── NO (opacity, color, outline, small scale)
        └── Generally safe, but still wrap in motion-safe variant
            for users who want ZERO motion

ANTI_PATTERNS

Anti-Pattern Problem Fix
Ignoring prefers-reduced-motion entirely Legal violation (EAA), excludes 5-10% of users Implement for every animated element
prefers-reduced-motion: reduce { * { animation: none !important; } } Kills ALL animation including essential (loading, progress) Selectively disable decorative motion only
Reduced motion = broken UI Elements pile up, overlap, or disappear without animation Test the reduced motion path as a first-class experience
Only testing on your own (non-vestibular) experience You cannot feel what vestibular disorder users feel Test with reduced motion ON for a full session
Parallax "but only a little" Even subtle parallax triggers vestibular symptoms in sensitive users No parallax, period
Auto-playing carousel without pause WCAG 2.2.2 violation Add visible pause button, or stop auto-advance
"Reduce motion" toggle buried in footer Users who need it cannot find it Put it in main settings or accessibility menu
Using transition: none as reduced motion fallback State changes become jarring (instant jump) Use fast opacity crossfade (150ms) instead of no transition at all
Spring animation with reducedMotion="user" in Framer Motion but no MotionConfig wrapper Spring still runs because Framer Motion needs the wrapper to detect preference Always wrap app in <MotionConfig reducedMotion="user">
Testing only with prefers-reduced-motion: reduce Misses users who have normal OS settings but are in motion-triggering contexts (car, bus) Provide in-app toggle as well

CHECKLIST — MOTION_ACCESSIBILITY_AUDIT

FOR_EVERY_PROJECT_RELEASE:

COMPLIANCE: - [ ] All pages tested with prefers-reduced-motion: reduce enabled - [ ] No content flashes more than 3 times per second (SC 2.3.1) - [ ] All auto-playing content has pause/stop/hide controls (SC 2.2.2) - [ ] Essential animations still work with reduced motion (loading, progress) - [ ] Non-essential animations disabled or replaced with safe alternatives

IMPLEMENTATION: - [ ] <MotionConfig reducedMotion="user"> wraps the app (Framer Motion projects) - [ ] Every CSS animation has a motion-reduce: or @media (prefers-reduced-motion: reduce) rule - [ ] SwiftUI animations check accessibilityReduceMotion environment variable - [ ] No parallax effects anywhere in the project - [ ] In-app motion toggle available in settings

TESTING: - [ ] Playwright tests with reducedMotion: "reduce" pass - [ ] Manual walkthrough completed with reduced motion enabled - [ ] Full user journey works identically (same tasks achievable) with reduced motion - [ ] Video content tested with PEAT for flash compliance - [ ] axe-core accessibility scan passes


REFERENCES

  • WCAG 2.1 — Understanding SC 2.3.1 (Three Flashes) — w3.org/WAI/WCAG21/Understanding/three-flashes-or-below-threshold
  • WCAG 2.1 — Understanding SC 2.2.2 (Pause, Stop, Hide) — w3.org/WAI/WCAG21/Understanding/pause-stop-hide
  • WCAG 2.1 — Understanding SC 2.3.3 (Animation from Interactions) — w3.org/WAI/WCAG21/Understanding/animation-from-interactions
  • MDN: prefers-reduced-motion — developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
  • "Designing Safer Web Animation For Motion Sensitivity" — alistapart.com (Val Head)
  • "Revisiting prefers-reduced-motion" — web.dev (Thomas Steiner)
  • Vestibular Disorders Association — vestibular.org
  • Photosensitive Epilepsy Analysis Tool — trace.umd.edu/peat
  • Apple Accessibility: Reduce Motion — developer.apple.com/documentation/swiftui/environmentvalues/accessibilityreducemotion
  • Framer Motion: Reduced Motion — framer.com/motion/guide-accessibility
  • European Accessibility Act (Directive 2019/882) — eur-lex.europa.eu