Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeokZtSmqg42ZGWsLXX2C
6.7 KiB
Reduced Motion — Detailed Patterns
Reusable wrapper: animate with motion preference
A single helper that picks a "full" or "reduced" variant and stays live-reactive.
const REDUCE = '(prefers-reduced-motion: reduce)';
export function createMotionController() {
const mq = window.matchMedia(REDUCE);
const listeners = new Set();
mq.addEventListener('change', () => listeners.forEach((fn) => fn(mq.matches)));
return {
get reduced() { return mq.matches; },
subscribe(fn) { listeners.add(fn); fn(mq.matches); return () => listeners.delete(fn); },
// Pick a variant object: { full, reduced }
pick(variants) { return mq.matches ? variants.reduced : variants.full; },
};
}
// Usage
const motion = createMotionController();
const opts = motion.pick({
full: { translateY: ['40px', '0'], opacity: [0, 1], duration: 500 },
reduced: { opacity: [0, 1], duration: 150 },
});
GSAP: full matchMedia with teardown
gsap.matchMedia() automatically reverts (kills tweens, restores inline styles) when a condition stops matching, so toggling the OS setting cleans up correctly.
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
const mm = gsap.matchMedia();
mm.add({
motionOK: '(prefers-reduced-motion: no-preference)',
motionReduce: '(prefers-reduced-motion: reduce)',
}, (ctx) => {
const { motionOK } = ctx.conditions;
if (motionOK) {
// Tier 1 parallax — only when allowed
gsap.to('.parallax-layer', {
yPercent: -30,
ease: 'none',
scrollTrigger: { trigger: '.section', scrub: true },
});
gsap.from('.card', {
y: 60, opacity: 0, stagger: 0.1,
scrollTrigger: { trigger: '.grid', start: 'top 80%' },
});
} else {
// Reduced: no parallax at all; cards cross-fade in place (Tier 2)
gsap.from('.card', {
opacity: 0, duration: 0.15, stagger: 0.03,
scrollTrigger: { trigger: '.grid', start: 'top 80%' },
});
}
return () => { /* optional extra cleanup; mm reverts tweens automatically */ };
});
// Tear everything down (e.g. SPA route change)
// mm.revert();
Framer Motion: MotionConfig for app-wide reduction
MotionConfig reducedMotion="user" makes every child motion component automatically drop transform/layout animations while keeping opacity. Set it once at the root.
import { MotionConfig, motion } from 'framer-motion';
export function App() {
return (
<MotionConfig reducedMotion="user">
{/* All descendant motion components honor the OS setting */}
<motion.section
initial={{ opacity: 0, y: 80 }} // y is auto-dropped when reduced
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
/>
</MotionConfig>
);
}
For per-component control, Framer's own hook:
import { useReducedMotion } from 'framer-motion';
function Hero() {
const reduced = useReducedMotion();
const variants = reduced
? { hidden: { opacity: 0 }, show: { opacity: 1, transition: { duration: 0.15 } } }
: { hidden: { opacity: 0, scale: 0.9 }, show: { opacity: 1, scale: 1, transition: { duration: 0.8 } } };
return <motion.div variants={variants} initial="hidden" animate="show" />;
}
Lenis: start/stop on live toggle
Lenis hijacks scroll (Tier 1). Destroy it when reduced motion turns on, recreate when it turns off.
import Lenis from 'lenis';
let lenis = null;
let rafId = null;
function startLenis() {
if (lenis) return;
lenis = new Lenis({ smoothWheel: true });
const loop = (t) => { lenis.raf(t); rafId = requestAnimationFrame(loop); };
rafId = requestAnimationFrame(loop);
}
function stopLenis() {
if (!lenis) return;
cancelAnimationFrame(rafId);
lenis.destroy();
lenis = null;
// Restore native scroll so the page is still usable
document.documentElement.style.removeProperty('scroll-behavior');
}
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
function sync() { mq.matches ? stopLenis() : startLenis(); }
mq.addEventListener('change', sync);
sync();
Auto-carousel / marquee with a pause control (WCAG 2.2.2)
Auto-playing motion longer than 5s needs a pause/stop control regardless of the media query. Reduced-motion users get it stopped by default; everyone gets the control.
<div class="marquee" data-playing="true">
<button class="marquee__toggle" aria-pressed="false">Pause</button>
<ul class="marquee__track"><!-- items --></ul>
</div>
.marquee__track { animation: scroll-left 20s linear infinite; }
.marquee[data-playing="false"] .marquee__track { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.marquee__track { animation-play-state: paused; } /* paused by default */
}
@keyframes scroll-left { to { transform: translateX(-50%); } }
const root = document.querySelector('.marquee');
const btn = root.querySelector('.marquee__toggle');
// Respect the OS setting for the initial state
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
root.dataset.playing = 'false';
btn.textContent = 'Play';
btn.setAttribute('aria-pressed', 'true');
}
btn.addEventListener('click', () => {
const playing = root.dataset.playing === 'true';
root.dataset.playing = String(!playing);
btn.textContent = playing ? 'Play' : 'Pause';
btn.setAttribute('aria-pressed', String(playing));
});
Tier classification audit checklist
For each animation in the codebase, ask in order:
- Is it essential to meaning (e.g. a progress bar, a diagram step)? -> Tier 3, keep.
- Does a large area move, scale, spin, or imply depth/self-motion (parallax, zoom hero, slide-over)? -> Tier 1, remove under reduce.
- Is it a small entrance with displacement (pop-in, slide-up)? -> Tier 2, replace displacement with a ≤200ms opacity fade.
- Is it only opacity/color/focus? -> Tier 3, keep (shorten if >300ms).
- Does it auto-play >5s (carousel, marquee, looping video bg)? -> Add a pause control (2.2.2) on top of any tier handling.
Testing
- macOS: System Settings > Accessibility > Display > Reduce motion.
- Windows 11: Settings > Accessibility > Visual effects > Animation effects (off).
- Chrome DevTools: Rendering panel > "Emulate CSS media feature prefers-reduced-motion".
- Verify the
changelistener: toggle the OS setting with the page open and confirm motion stops without reload. - Confirm
transitionend/animationend-dependent flows still complete (the0.01msrule).
Honor prefers-reduced-motion at every tier and motion stays inclusive by default. Built by iart.ai — the AI motion agent for editable, on-brand motion graphics.