accessible-animation
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAccessible Animation (Reduced Motion, Tiered)
无障碍动画(减少动效、分层处理)
Honor as a graceful degradation, not a kill switch. The OS-level setting (macOS: Settings > Accessibility > Display > Reduce motion; Windows: Settings > Accessibility > Visual effects > Animation effects; iOS/Android equivalents) signals that vestibular-triggering motion causes nausea, dizziness, or migraines. The correct response is to remove the dangerous motion while keeping orientation cues.
prefers-reduced-motion: reduce将视为一种优雅降级方案,而非完全禁用开关。系统级设置(macOS:设置 > 辅助功能 > 显示 > 减少动态效果;Windows:设置 > 辅助功能 > 视觉效果 > 动画效果;iOS/Android对应选项)表明用户会因前庭触发类动效感到恶心、头晕或偏头痛。正确的应对方式是移除危险动效,同时保留方向提示。
prefers-reduced-motion: reduceWhen to use
适用场景
Use when adding or auditing animation in a web UI, when a user reports motion sickness or WCAG findings, or when integrating animation libraries (GSAP, Framer Motion, Lenis smooth scroll, Anime.js, CSS keyframes) into an accessible product.
在网页UI中添加或审核动画时、用户反馈晕动症问题或WCAG合规问题时,或是将动画库(GSAP、Framer Motion、Lenis平滑滚动、Anime.js、CSS关键帧)集成到无障碍产品中时使用。
The core principle: tier motion, do not nuke it
核心原则:分层处理动效,而非完全移除
Removing all animation is a common over-correction. An instant state change with no transition can be more disorienting (elements teleport) and removes useful affordances (focus rings, loading spinners that communicate progress). Sort every animation into three tiers:
- Tier 1 — Remove entirely (vestibular triggers). Parallax; large-area slides/translates across the viewport; scale/zoom of large elements; 3D rotation and spin; continuous auto-playing carousels and marquees; smooth-scroll hijacking (Lenis/Locomotive); scroll-jacked pinned scenes; motion-path animation; anything that moves a large portion of the screen or implies depth/self-motion.
- Tier 2 — Soften / shorten. Replace movement with a short opacity fade (≤200ms). Reduce distance and duration. Keep the fact of a transition (so elements don't teleport) but strip the displacement. Replace a 600ms slide-in-from-left with a 150ms cross-fade.
- Tier 3 — Always keep. Opacity fades, color/background transitions, focus-ring transitions, loading indicators, and motion that is essential to meaning (WCAG allows essential animation). These rarely trigger vestibular responses because they imply no self-motion.
移除所有动效是常见的过度修正方式。无过渡的即时状态切换可能更令人困惑(元素突然“瞬移”),还会移除有用的提示(焦点环、传达进度的加载 spinner)。将每个动画分为三个层级:
- 层级1 — 完全移除(前庭触发类):视差效果;视口内大面积滑动/平移;大元素的缩放;3D旋转和自旋;持续自动播放的轮播和跑马灯;平滑滚动劫持(Lenis/Locomotive);滚动固定场景;路径动效;任何移动屏幕大部分区域或暗示深度/自我运动的动效。
- 层级2 — 弱化/缩短时长:用短时长透明度淡入淡出(≤200ms)替代位移动效。减少移动距离和时长。保留过渡的存在(避免元素瞬移),但移除位移。将600ms的从左侧滑入替换为150ms的交叉淡入。
- 层级3 — 始终保留:透明度淡入淡出、颜色/背景过渡、焦点环过渡、加载指示器,以及对含义至关重要的动效(WCAG允许必要动画)。这些动效很少触发前庭反应,因为它们不暗示自我运动。
CSS layer: media-query gating
CSS层:媒体查询控制
Write motion as the default, then override under the reduce query. Prefer a global safety net plus targeted overrides.
css
/* 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 */
}
}The global net is a backstop. Layer intentional, tiered overrides on top so Tier 3 motion survives:
css
.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 */
}Use the positive query to opt motion IN, which is the safest pattern for elaborate effects:
(prefers-reduced-motion: no-preference)css
.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 */
}将动效设为默认状态,然后在减少动效查询下覆盖样式。优先使用全局安全网加针对性覆盖。
css
/* 全局安全网:中和失控动效,但不要盲目设为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; /* 禁用平滑滚动 */
}
}全局安全网是兜底方案。在此之上叠加有意的分层覆盖,让层级3的动效得以保留:
css
.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)css
.hero { opacity: 1; } /* 默认可见、静态 */
@media (prefers-reduced-motion: no-preference) {
.hero { animation: zoom-in 1.2s ease both; } /* 仅在允许时执行动画 */
}JS layer: matchMedia gating
JS层:matchMedia控制
CSS cannot gate JS-driven animation (GSAP timelines, Framer's , canvas/WebGL, Lenis). Read the same signal via and react to live changes (users toggle the OS setting without reloading).
animatematchMediajs
const 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);
}CSS无法控制JS驱动的动画(GSAP时间线、Framer的、canvas/WebGL、Lenis)。通过读取相同信号,并响应实时变化(用户无需重新加载页面即可切换系统设置)。
animatematchMediajs
const 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);
}Library gating, applied as one reusable layer
库级控制,作为可复用的统一层
js
// 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);
}js
// 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);
}React: a useReducedMotion
hook
useReducedMotionReact:useReducedMotion
钩子
useReducedMotionjsx
import { 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;
}jsx
// 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 }}
/>
);
}Initialize SSR-rendered state to (motion) and re-sync in to avoid a hydration mismatch; the OS value is only known client-side.
falseuseEffectjsx
import { 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;
}jsx
// 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 }}
/>
);
}SSR渲染的初始状态设为(启用动效),并在中重新同步,避免hydration不匹配;系统设置值仅在客户端可知。
falseuseEffectWCAG mapping
WCAG映射
- 2.3.3 Animation from Interactions (AAA). Motion triggered by interaction (scroll, hover) must be disable-able unless essential. Honoring is the standard technique.
prefers-reduced-motion - C39 (CSS technique). Use the query to prevent motion. This is the named sufficient technique.
prefers-reduced-motion - 2.2.2 Pause, Stop, Hide (A). Any auto-playing motion lasting >5s must offer pause/stop. Auto-carousels and marquees fail this regardless of the media query — give a control too.
- 2.3.3 交互触发的动画(AAA级):交互(滚动、悬停)触发的动效应可禁用,除非是必要动效。遵循是标准实现方式。
prefers-reduced-motion - C39(CSS技术):使用查询阻止动效。这是官方认可的充分实现技术。
prefers-reduced-motion - 2.2.2 暂停、停止、隐藏(A级):任何持续超过5秒的自动播放动效应提供暂停/停止选项。无论媒体查询如何,自动轮播和跑马灯都不符合要求——还需添加控制按钮。
Deliver & verify (standalone HTML)
交付与验证(独立HTML)
Packaged 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
For a self-contained demo the deliverable is one HTML file that opens directly in a browser — markup, CSS, and any JS (GSAP/ from CDN) inline, no build step. For this skill the verification is the point: you must screenshot both the normal and the reduced-motion path and confirm the tiering is correct.
matchMediaOutput contract:
- One file with the full motion AND its tiered reduced-motion overrides (CSS query +
.html/matchMediafor JS-driven motion).gsap.matchMedia() - A seek harness to freeze a frame, plus a way to force the reduced-motion code path for screenshots.
Seek harness — freeze a frame, force the preference. freezes the timeline; forces the reduced path even when the OS setting is off (CSS can't be toggled from JS, so query the same flag you gate JS on, and screenshot with the OS/browser preference emulated for the CSS layer):
?t=N?reduce=1html
<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>Verify loop — screenshot BOTH states: capture the normal path AND the reduced path, mid-animation each, and confirm Tier-1 motion is gone, Tier-2 is a short fade, Tier-3 survives. Emulate the preference for the CSS layer (Playwright ; the agent's browser tool can emulate it too):
--reduced-motion=reducebash
npx 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.pngBefore you finish:
- Opens standalone — no console errors, CDN (if any) loads.
- The freeze + (and
?reduce=1) reliably exercises both code paths.--reduced-motion=reduce - Screenshotted in BOTH states, mid-animation — normal looks intentional, reduced strips Tier-1/softens Tier-2.
- honored as tiering, not a kill switch — no teleporting, Tier-3 fades/focus rings/spinners kept; any >5s auto-motion still has a pause control (2.2.2).
prefers-reduced-motion - Easing is intentional in both paths — no accidental , durations sane (reduced fades ≤200ms,
linearnot0.01ms).0s
打包工具(目录):scripts/会冻结scripts/seek-shot.sh anim.html 0 1.5 3测试环境并截取每个时刻的截图;?t=N将截图拼接成一张概览图,便于快速查看。详见scripts/contact-sheet.sh sheet.png frame-*.png。scripts/README.md
自包含演示的交付物为可直接在浏览器中打开的单个HTML文件——标记、CSS和所有JS(GSAP/从CDN加载)均内联,无需构建步骤。对于本技能而言,验证是核心环节:必须截取正常动效和减少动效两种状态的截图,确认分层处理正确。
matchMedia输出规范:
- 单个文件,包含完整动效及其分层减少动效覆盖(CSS查询 + JS驱动动效的
.html/matchMedia)。gsap.matchMedia() - 帧冻结测试环境,以及强制触发减少动效代码路径的方式,便于截图。
帧冻结测试环境 — 冻结帧、强制偏好设置:冻结时间线;强制启用减少动效路径,即使系统设置未开启(CSS无法通过JS切换,因此可在JS中查询同一标识,同时通过浏览器/系统模拟偏好设置来测试CSS层):
?t=N?reduce=1html
<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>验证流程 — 截取两种状态的截图:分别捕获正常动效和减少动效路径的动画中期截图,确认层级1动效已移除、层级2动效已弱化、层级3动效保留。模拟CSS层的偏好设置(Playwright ;Agent的浏览器工具也可模拟):
--reduced-motion=reducebash
npx 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完成前检查:
- 可独立打开——无控制台错误,CDN(若使用)加载正常。
- 冻结功能 + (及
?reduce=1)可可靠测试两种代码路径。--reduced-motion=reduce - 已截取两种状态的动画中期截图——正常动效符合预期,减少动效已移除层级1/弱化层级2。
- 被视为分层处理而非禁用开关——无元素瞬移,层级3的淡入/焦点环/spinner保留;任何超过5秒的自动动效仍有暂停控制(符合2.2.2)。
prefers-reduced-motion - 两种路径的缓动效果均符合预期——无意外,时长合理(减少动效的淡入≤200ms,使用
linear而非0.01ms)。0s
Quick reference
速查表
| 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 | 保留(必要反馈) |
Gotchas
常见陷阱
- can break code waiting on
animation-duration: 0s/animationend; usetransitionendso the event still fires.0.01ms - The global reset alone over-removes Tier 3 motion; always layer intentional overrides.
* - Forgetting leaves infinite spins running at near-zero duration (CPU churn, still animating).
animation-iteration-count: 1 - No media query covers JS/canvas/WebGL — gate those with or the loop never stops.
matchMedia - Toggling the OS setting fires no page reload; without a listener the page keeps its stale state.
change - SSR: reading during render throws or mismatches; default to motion and sync in an effect.
matchMedia - Auto-playing motion needs a visible pause control even when reduced-motion is off (2.2.2 is Level A).
- 会破坏等待
animation-duration: 0s/animationend的代码;使用transitionend确保事件仍会触发。0.01ms - 仅全局重置会过度移除层级3动效;始终叠加有意的覆盖规则。
* - 忘记设置会让无限旋转动效以接近0的时长继续运行(占用CPU,仍在动画)。
animation-iteration-count: 1 - 媒体查询无法覆盖JS/canvas/WebGL动效——需用控制,否则循环不会停止。
matchMedia - 切换系统设置不会触发页面重载;若无监听器,页面会保持旧状态。
change - SSR:渲染期间读取会抛出错误或导致不匹配;默认启用动效,在effect中同步状态。
matchMedia - 自动播放的动效即使在减少动效关闭时也需要可见的暂停控制(2.2.2为A级要求)。
Reference files
参考文件
- — Full GSAP
references/patterns.mdteardown, Framer MotionmatchMedia, Lenis start/stop on live toggle, a reusableMotionConfigwrapper, marquee with pause control, and a tier-classification audit checklist.animateWithMotionPreference
- — 完整的GSAP
references/patterns.md拆解、Framer MotionmatchMedia、Lenis实时切换启停、可复用的MotionConfig包装器、带暂停控制的跑马灯,以及动效层级分类审核清单。animateWithMotionPreference