关于 accessible-animation
用于创建和审查网页动画的设计技能,强调支持减少动态效果偏好。它将动画分为移除、弱化和保留三类,并提供 CSS、JavaScript 模式及截图验证流程。
主要功能
- 01减少动态效果的三级规则
- 02CSS 媒体查询模式
- 03JavaScript matchMedia 控制
- 04React 减少动态效果 Hook
- 05独立演示验证
适用场景
- 01审查可能引发不适的动画
- 02调整 GSAP 或 Framer Motion 效果
- 03制作支持减少动态效果的网页演示
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ iart-ai/ web-animation-skills --skill accessible-animation安装后在 Claude Code 中输入/accessible-animation确认已加载。
附带文件README.mdreferences/patterns.md
详细介绍
无障碍动画(减少动态效果,分级处理)
将 prefers-reduced-motion: reduce 视为一种优雅降级,而非一键关闭。操作系统级设置(macOS:Settings > Accessibility > Display > Reduce motion;Windows:Settings > Accessibility > Visual effects > Animation effects;iOS/Android 中的对应设置)表示,可能引发前庭不适的动态效果会导致恶心、眩晕或偏头痛。正确做法是移除有害的动态效果,同时保留方向提示。
适用场景
在网页 UI 中添加或审查动画、用户报告晕动症或 WCAG 审查发现问题,或在无障碍产品中集成动画库(GSAP、Framer Motion、Lenis smooth scroll、Anime.js、CSS keyframes)时使用。
核心原则:动态效果分级处理,不要全部关闭
移除所有动画是常见的过度修正。没有任何过渡的即时状态变化可能更令人迷失方向(元素仿佛瞬间传送),也会去掉实用的提示(焦点环、用于传达进度的加载动画)。将每种动画归入以下三个级别:
- 第 1 级 — 完全移除(前庭刺激因素)。 视差;在视口范围内大幅滑动或平移;大型元素的缩放;3D 旋转和转动;持续自动播放的轮播和跑马灯;劫持滚动的平滑滚动(Lenis/Locomotive);由滚动劫持的固定场景;运动路径动画;任何会让屏幕大范围移动或产生深度感/自身运动感的效果。
- 第 2 级 — 弱化/缩短。 改为短暂的透明度淡入淡出(≤200ms)。减小移动距离和持续时间。保留过渡这一事实(避免元素瞬间出现),但去掉位移效果。将 600ms 的从左侧滑入改为 150ms 的交叉淡化。
- 第 3 级 — 始终保留。 透明度淡入淡出、颜色/背景过渡、焦点环过渡、加载指示器,以及对表达含义至关重要的动态效果(WCAG 允许必要的动画)。这些效果通常不会引发前庭反应,因为它们不会产生自身运动感。
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 */
}
}
全局安全网只是兜底措施。在此基础上叠加有意设置、分级处理的覆盖规则,以便保留第 3 级动态效果:
.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 */
}
JS 层:使用 matchMedia 进行控制
CSS 无法控制由 JS 驱动的动画(GSAP 时间轴、Framer 的 animate、canvas/WebGL、Lenis)。通过 matchMedia 读取相同的信号,并响应实时变化(用户可以在不重新加载页面的情况下切换操作系统设置)。
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);
}
在库中统一添加控制层,以便复用
// 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);
}
React:useReducedMotion Hook
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;
}
// 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 }}
/>
);
}
将 SSR 渲染状态初始化为 false(启用动态效果),并在 useEffect 中重新同步,以避免 hydration 不匹配;只有在客户端才能获取操作系统设置值。
WCAG 对应条款
- 2.3.3 交互触发的动画(AAA)。 由交互(滚动、悬停)触发的动态效果必须可以禁用,除非它们是必要的。遵循
prefers-reduced-motion是标准做法。 - C39(CSS 技术)。 使用
prefers-reduced-motion查询来阻止动态效果。这是指定的充分技术。 - 2.2.2 暂停、停止、隐藏(A)。 任何持续时间超过 5s 的自动播放动态效果都必须提供暂停/停止功能。无论媒体查询如何设置,自动轮播和跑马灯都不符合要求,因此也要提供控制选项。
交付并验证(独立 HTML)
打包的辅助脚本(
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(来自 CDN 的 GSAP/matchMedia)都内联,不需要构建步骤。对于本技能,验证是重点:必须同时为常规路径和减少动态效果路径截图,并确认分级正确。
输出要求:
- 一个
.html文件,包含完整动态效果和分级的减少动态效果覆盖规则(CSS 查询,以及适用于 JS 驱动动态效果的matchMedia/gsap.matchMedia())。 - 一个用于冻结画面的定位测试代码,以及一种在截图时强制走减少动态效果代码路径的方法。
定位测试代码——冻结画面,强制指定偏好。 ?t=N 会冻结时间线;即使操作系统设置关闭,?reduce=1 也会强制走减少动态效果路径(CSS 无法通过 JS 切换,因此应查询用于控制 JS 的同一标志,并在为 CSS 层模拟操作系统/浏览器偏好后截图):
<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>
**验证流程——为两种状态都截图:**分别捕获常规路径和减少动态效果路径,并确保每种状态都处于动画中段;确认 Tier-1 动态效果已移除、Tier-2 动态效果是短暂淡入淡出、Tier-3 动态效果保留。为 CSS 层模拟偏好(Playwright 使用 --reduced-motion=reduce;Agent 的浏览器工具也可以模拟):
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)能可靠地触发两种代码路径。 - 两种状态都在动画中段完成截图——常规状态效果自然,减少动态效果状态会移除 Tier-1、减弱 Tier-2。
prefers-reduced-motion应按分级处理,而非作为总开关——不得瞬移;保留 Tier-3 淡入淡出、焦点环和加载动画;任何超过 5 秒的自动动态效果仍须提供暂停控件(2.2.2)。- 两种路径的缓动都经过合理设计——没有意外的
linear,时长合理(减少动态效果时淡入淡出 ≤200ms;使用0.01ms而非0s)。
快速参考
| 动态效果类型 | 层级 | 减少动态效果时的处理 |
|---|---|---|
| 视差/景深 | 1 | 移除(transform: none) |
| 大幅滑动/平移 | 1 | 移除或替换为淡入淡出 |
| 大幅缩放/放大 | 1 | 移除 |
| 旋转/3D 旋转 | 1 | 移除(animation: none) |
| 自动轮播/跑马灯 | 1 | 停止并提供暂停控件(2.2.2) |
| 平滑滚动(Lenis) | 1 | 不要实例化 |
| 小幅滑动/弹入 | 2 | 交叉淡入淡出 ≤200ms |
| 透明度淡入淡出 | 3 | 保留(如果耗时较长则缩短) |
| 颜色/焦点环过渡 | 3 | 保留 |
| 加载动画 | 3 | 保留(必要的反馈) |
注意事项
animation-duration: 0s可能会导致等待animationend/transitionend的代码出错;请使用0.01ms,确保事件仍会触发。- 仅使用全局
*重置会过度移除 Tier 3 动态效果;务必添加有针对性的覆盖规则。 - 忘记设置
animation-iteration-count: 1会使无限旋转动画以接近零的时长继续运行(造成 CPU 空转,动画仍在进行)。 - 媒体查询无法覆盖 JS/canvas/WebGL;应使用
matchMedia控制,否则循环永远不会停止。 - 切换操作系统设置不会触发页面重新加载;如果没有
change监听器,页面会继续使用过时状态。 - SSR:在渲染期间读取
matchMedia会报错或导致不匹配;默认启用动态效果,并在 effect 中同步。 - 即使关闭减少动态效果,自动播放的动态效果仍须提供可见的暂停控件(2.2.2 属于 A 级)。
参考文件
references/patterns.md— 完整的 GSAPmatchMedia清理、Framer MotionMotionConfig、实时切换时启动/停止 Lenis、可复用的animateWithMotionPreference封装、带暂停控件的跑马灯,以及动态效果分级审查清单。