Loading...
Loading...
Compare original and translation side by side
prefers-reduced-motion: reduceprefers-reduced-motion: reduce/* Global safety net: neutralize runaway motion but DO NOT set 0s blindly,
which can break JS that waits for transitionend/animationend. Use 0.01ms. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; /* stop infinite spins/marquees */
transition-duration: 0.01ms !important;
scroll-behavior: auto !important; /* kill smooth scroll */
}
}.card {
transition: transform 400ms ease, opacity 400ms ease, background-color 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.card {
/* Tier 2: drop the transform (movement), keep opacity + color (Tier 3) */
transition: opacity 150ms ease, background-color 150ms ease;
}
.parallax-layer { transform: none !important; } /* Tier 1: remove */
.hero-zoom { animation: none !important; } /* Tier 1: remove */
}(prefers-reduced-motion: no-preference).hero { opacity: 1; } /* visible, static by default */
@media (prefers-reduced-motion: no-preference) {
.hero { animation: zoom-in 1.2s ease both; } /* only animate when allowed */
}/* 全局安全网:中和失控动效,但不要盲目设为0s,
否则会破坏等待transitionend/animationend的JS代码。使用0.01ms。 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; /* 停止无限旋转/跑马灯 */
transition-duration: 0.01ms !important;
scroll-behavior: auto !important; /* 禁用平滑滚动 */
}
}.card {
transition: transform 400ms ease, opacity 400ms ease, background-color 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.card {
/* 层级2:移除位移变换(移动),保留透明度+颜色(层级3) */
transition: opacity 150ms ease, background-color 150ms ease;
}
.parallax-layer { transform: none !important; } /* 层级1:移除 */
.hero-zoom { animation: none !important; } /* 层级1:移除 */
}(prefers-reduced-motion: no-preference).hero { opacity: 1; } /* 默认可见、静态 */
@media (prefers-reduced-motion: no-preference) {
.hero { animation: zoom-in 1.2s ease both; } /* 仅在允许时执行动画 */
}animatematchMediaconst REDUCE_QUERY = '(prefers-reduced-motion: reduce)';
export function prefersReducedMotion() {
return typeof window !== 'undefined'
&& window.matchMedia
&& window.matchMedia(REDUCE_QUERY).matches;
}
// Live updates: listen so toggling the OS setting takes effect immediately.
export function onReducedMotionChange(cb) {
const mq = window.matchMedia(REDUCE_QUERY);
const handler = (e) => cb(e.matches);
mq.addEventListener('change', handler); // modern API
return () => mq.removeEventListener('change', handler);
}animatematchMediaconst REDUCE_QUERY = '(prefers-reduced-motion: reduce)';
export function prefersReducedMotion() {
return typeof window !== 'undefined'
&& window.matchMedia
&& window.matchMedia(REDUCE_QUERY).matches;
}
// 实时更新:监听事件,让系统设置切换后立即生效。
export function onReducedMotionChange(cb) {
const mq = window.matchMedia(REDUCE_QUERY);
const handler = (e) => cb(e.matches);
mq.addEventListener('change', handler); // 现代API
return () => mq.removeEventListener('change', handler);
}// GSAP — use matchMedia(): GSAP reverts conditional setups on change.
import gsap from 'gsap';
const mm = gsap.matchMedia();
mm.add({
motionOK: '(prefers-reduced-motion: no-preference)',
motionReduce: '(prefers-reduced-motion: reduce)',
}, (ctx) => {
const { motionOK } = ctx.conditions;
if (motionOK) {
gsap.from('.hero', { y: 80, opacity: 0, duration: 1 }); // Tier 1 move
} else {
gsap.from('.hero', { opacity: 0, duration: 0.15 }); // Tier 2 fade
}
});
// Lenis smooth scroll — do not instantiate at all when reduced.
import Lenis from 'lenis';
let lenis = null;
if (!prefersReducedMotion()) {
lenis = new Lenis();
const raf = (t) => { lenis.raf(t); requestAnimationFrame(raf); };
requestAnimationFrame(raf);
}// GSAP — 使用matchMedia():GSAP会在设置变化时还原条件化配置。
import gsap from 'gsap';
const mm = gsap.matchMedia();
mm.add({
motionOK: '(prefers-reduced-motion: no-preference)',
motionReduce: '(prefers-reduced-motion: reduce)',
}, (ctx) => {
const { motionOK } = ctx.conditions;
if (motionOK) {
gsap.from('.hero', { y: 80, opacity: 0, duration: 1 }); // 层级1位移
} else {
gsap.from('.hero', { opacity: 0, duration: 0.15 }); // 层级2淡入
}
});
// Lenis平滑滚动 — 当启用减少动效时完全不实例化。
import Lenis from 'lenis';
let lenis = null;
if (!prefersReducedMotion()) {
lenis = new Lenis();
const raf = (t) => { lenis.raf(t); requestAnimationFrame(raf); };
requestAnimationFrame(raf);
}useReducedMotionuseReducedMotionimport { useState, useEffect } from 'react';
export function useReducedMotion() {
const query = '(prefers-reduced-motion: reduce)';
const get = () =>
typeof window !== 'undefined' && window.matchMedia
? window.matchMedia(query).matches
: false;
const [reduced, setReduced] = useState(get);
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setReduced(mq.matches);
onChange(); // sync after hydration
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
return reduced;
}// Framer Motion has its own useReducedMotion(); the hook above also works.
import { motion } from 'framer-motion';
function Card() {
const reduced = useReducedMotion();
return (
<motion.div
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0.15 : 0.5 }}
/>
);
}falseuseEffectimport { useState, useEffect } from 'react';
export function useReducedMotion() {
const query = '(prefers-reduced-motion: reduce)';
const get = () =>
typeof window !== 'undefined' && window.matchMedia
? window.matchMedia(query).matches
: false;
const [reduced, setReduced] = useState(get);
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setReduced(mq.matches);
onChange(); // hydration后同步状态
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
return reduced;
}// Framer Motion自带useReducedMotion(); 上述钩子同样适用。
import { motion } from 'framer-motion';
function Card() {
const reduced = useReducedMotion();
return (
<motion.div
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0.15 : 0.5 }}
/>
);
}falseuseEffectprefers-reduced-motionprefers-reduced-motionprefers-reduced-motionprefers-reduced-motionPackaged helper ():scripts/freezes thescripts/seek-shot.sh anim.html 0 1.5 3harness and screenshots each moment;?t=Ntiles them for one-glance review. Seescripts/contact-sheet.sh sheet.png frame-*.png.scripts/README.md
matchMedia.htmlmatchMediagsap.matchMedia()?t=N?reduce=1<script>
const q = new URLSearchParams(location.search);
const reduce = q.get("reduce") === "1"
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// build motion conditionally on `reduce` (Tier-1 removed, Tier-2 softened, Tier-3 kept)
const t = q.get("t");
if (t !== null) { tl.pause(); tl.seek(parseFloat(t)); } // GSAP; CSS: el.style.animationDelay=(-t)+"s"; playState="paused"
window.__ready = true;
</script>--reduced-motion=reducenpx playwright screenshot --wait-for-timeout=500 "file://$PWD/demo.html?t=0.6" normal.png
npx playwright screenshot --reduced-motion=reduce --wait-for-timeout=500 "file://$PWD/demo.html?t=0.6&reduce=1" reduced.png?reduce=1--reduced-motion=reduceprefers-reduced-motionlinear0.01ms0s打包工具(目录):scripts/会冻结scripts/seek-shot.sh anim.html 0 1.5 3测试环境并截取每个时刻的截图;?t=N将截图拼接成一张概览图,便于快速查看。详见scripts/contact-sheet.sh sheet.png frame-*.png。scripts/README.md
matchMedia.htmlmatchMediagsap.matchMedia()?t=N?reduce=1<script>
const q = new URLSearchParams(location.search);
const reduce = q.get("reduce") === "1"
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 根据`reduce`条件构建动效(移除层级1、弱化层级2、保留层级3)
const t = q.get("t");
if (t !== null) { tl.pause(); tl.seek(parseFloat(t)); } // GSAP;CSS:el.style.animationDelay=(-t)+"s"; playState="paused"
window.__ready = true;
</script>--reduced-motion=reducenpx playwright screenshot --wait-for-timeout=500 "file://$PWD/demo.html?t=0.6" normal.png
npx playwright screenshot --reduced-motion=reduce --wait-for-timeout=500 "file://$PWD/demo.html?t=0.6&reduce=1" reduced.png?reduce=1--reduced-motion=reduceprefers-reduced-motionlinear0.01ms0s| Motion type | Tier | Reduced behavior |
|---|---|---|
| Parallax / depth | 1 | Remove ( |
| Large slide / translate | 1 | Remove or replace with fade |
| Scale / zoom (large) | 1 | Remove |
| Spin / 3D rotate | 1 | Remove ( |
| Auto carousel / marquee | 1 | Stop + provide pause control (2.2.2) |
| Smooth-scroll (Lenis) | 1 | Don't instantiate |
| Small slide / pop-in | 2 | Cross-fade ≤200ms |
| Opacity fade | 3 | Keep (shorten if long) |
| Color / focus-ring transition | 3 | Keep |
| Loading spinner | 3 | Keep (essential feedback) |
| 动效类型 | 层级 | 减少动效后的表现 |
|---|---|---|
| 视差/深度效果 | 1 | 移除( |
| 大面积滑动/平移 | 1 | 移除或替换为淡入 |
| (大元素)缩放 | 1 | 移除 |
| 自旋/3D旋转 | 1 | 移除( |
| 自动轮播/跑马灯 | 1 | 停止 + 提供暂停控制(2.2.2) |
| 平滑滚动(Lenis) | 1 | 不实例化 |
| 小面积滑动/弹出 | 2 | ≤200ms交叉淡入 |
| 透明度淡入淡出 | 3 | 保留(若时长过长则缩短) |
| 颜色/焦点环过渡 | 3 | 保留 |
| 加载spinner | 3 | 保留(必要反馈) |
animation-duration: 0sanimationendtransitionend0.01ms*animation-iteration-count: 1matchMediachangematchMediaanimation-duration: 0sanimationendtransitionend0.01ms*animation-iteration-count: 1matchMediachangematchMediareferences/patterns.mdmatchMediaMotionConfiganimateWithMotionPreferencereferences/patterns.mdmatchMediaMotionConfiganimateWithMotionPreference