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