动画 是做什么的?
本技能构建动画。它扩展了 product-ui/references/values.md 中动画部分的内容,该文件包含了已确定的数值(缓动曲线、按下缩放、弹跳、错开、时长表、弹窗和布局时长、移动距离、退出与进入比例)。此处不重复这些数值;本技能增加了决策顺序、标记、方案和故障模式。
在任何决策之前,应用 product-ui §1(品味日志和优先级)和 §5(哪个技能负责什么)。
决策顺序
按从上到下的顺序执行。每一步都可以结束任务。零行动画是一个有效的结果,需要明确说明。
1. 应该动吗?频率门控
询问一个用户每天看到这个元素多少次。等级划分在 values.md 中。实践中:
- 由键盘触发,或每天看到 100 次以上:无动效。原因:重复动效会变成延迟。
- 每天看到数十次(悬停、标签切换、行选择):无动效,或瞬间颜色变化。
- 用户正在阅读或操作的数据(表格中的数字、实时仪表盘中的图表)绝不为样式而移动。原因:动效会将视线从正在阅读的内容上移开。
- 如果门控判定为否,则编写无动画代码。说明命中了哪个等级,并提供静态替代方案(瞬间状态变化)。
2. 命名目的
选择一个词:空间(它从哪里来或到哪里去)、状态(发生了变化)、反馈(我听到了)、连续性(防止跳跃)、解释(仅限罕见场景)。无法命名:停止。原因:频繁元素上“看起来不错”是导致界面感觉缓慢的主要原因。
3. 选择工具:最廉价且有效的方法
| 需求 | 工具 |
|---|---|
| 通过类、属性、悬停或按压切换的状态 | CSS 过渡 |
从 display: none、弹出框或对话框进入 | @starting-style + transition-behavior: allow-discrete |
| 一次性序列、错开,或必须在页面繁忙时运行的动效 | CSS @keyframes |
JS 决定何时开始;需要取消、反转、finished 或 FLIP | WAAPI el.animate() |
| 弹簧、速度传递、卸载 React 节点的退出、共享布局 | Motion (motion/react) |
如果是一个完整的组件(菜单、弹窗、抽屉),使用自带焦点处理功能的库,并询问 pick-library。切勿为了一个淡入效果而安装 Motion。原因:CSS 在主线程外运行,且不增加包体积。
4. 属性
- 对
transform和opacity进行动画;仅当模糊值 ≤ 2px 时使用filter。命名每个属性;绝不用transition: all。 - 高度和布局变化:参见
references/recipes.md(网格行、FLIP)。绝不用width、top、left或margin来移动元素。原因:每个都会在每一帧强制重排布局。 - 绝不对
box-shadow进行动画。改为淡入伪元素的opacity。 translate百分比相对于元素自身尺寸,因此translateY(100%)适用于任何高度的抽屉和弹窗。
5. 曲线和时长,与用途匹配
永远不要选择最接近你猜测的数值。说明用途(“菜单关闭”、“图标切换”),然后从 references/tokens.md 中取对应的行。如果没有与用途匹配的行,则以该用途命名添加一个新的标记;不要扭曲现有标记。原因:标记是对动效用途的声明,因此一个近似值但用途错误就是错误的标记。
- 进入或退出:
--ease-out。在屏幕上移动:--ease-in-out。抽屉:--ease-drawer。颜色:ease。加载图标:linear。绝不用ease-in。 - 任何可拖拽或可在中途中断的元素使用弹簧,而不是曲线。原因:曲线无法接受用户手部带来的速度。
- 感觉慢:先缩短时长,再缩短距离,最后才调整曲线。
6. 中断
- 任何用户在一秒内能触发两次的元素使用过渡或弹簧,绝不用
@keyframes。原因:过渡从当前值重新定向;关键帧会重启并跳跃。 - WAAPI:在读取当前值后取消旧的动画,并基于该值开始新的动画(
references/recipes.md)。 - 一个开关被连续快速触发 10 次必须最终处于正确状态且没有跳跃。测试它。
7. 退出
- 默认退出是淡出,比进入更短(标记文件中有每对设置)。
- 任何有方向的元素(抽屉、面板、被滑动的弹窗)按其来路退出。
- 退出的元素立即停止占据空间,因此兄弟元素在其淡出时填补空隙。
- 退出是同时进行且绝不交错。原因:用户已经决定离开。
8. 减少动效,与动画一起提供
不透明度和颜色保持。滑动、缩放、模糊和弹跳消失;滑动变为淡出,弹跳变为 0。绝不用动效作为唯一提示。模式见 references/recipes.md。
编排
在设定时间之前,为每个移动元素分配角色:
| 角色 | 处理方式 |
|---|---|
| 主角(最值得注意的元素) | 先动,移动距离最大 |
| 配角 | 跟随,移动距离较小 |
| 装饰 | 原地淡入,或不动 |
| 静态(容器、框架) | 从不移动 |
- 每个容器一次进入。如果面板滑入,其内容已经就位。原因:两次分层进入会让用户等待两次。
- 按重要性顺序排列,而非 DOM 顺序。延迟保持在 values.md 的步骤(项目之间 40ms,标题、文本和操作之间 100ms)。通过角色而不是延迟来变化距离和时长。
- 列表错开限制在索引 6(最长 240ms 延迟);之后的项目共享该延迟。播放期间输入仍可工作。
- 绝不对菜单、命令面板、标签内容或任何重复打开的内容进行错开。
- 保持静止:用户即将点击的任何内容、页面再次加载时已显示在屏幕上的任何内容,以及在最后一次更新后 150ms 内到达的更新(跳转到结束状态)。
- 方向遵循含义:下一个从右侧进入,上一个从左侧进入;用户触发的内容从其触发器向外生长。
需要时加载
- references/tokens.md:当需要精确的时长、距离、缩放、模糊、缓动或弹簧值,以及需要对现有代码库进行标记化时加载。
- references/recipes.md:构建菜单、弹出框、对话框、抽屉、弹窗、列表进入、图标切换、高度变化、标签指示器或数字变化时加载。
- references/gestures.md:处理拖拽、滑动、轻划、面板,或任何跟随指针的操作时加载。
- references/performance.md:当动效掉帧时,在尝试使用
will-change之前,以及当更新快速到达时加载。
对照表
| 已坏 | 已修 | 原因 |
|---|---|---|
.menu { transition: all 300ms ease-in } | transition: opacity 200ms, transform 200ms var(--ease-out) | 命名属性;ease-in 感觉迟缓;菜单 ≤ 200ms |
scale(0) 到 scale(1) | scale(0.95) 和 opacity: 0 到 1 | 没有元素从无到有 |
| 弹出框从其中心缩放 | transform-origin: var(--transform-origin) | 它应该从其触发器处向外生长 |
.open { animation: slide-in 300ms } | transition: transform 作用于切换状态 | 关键帧在中断时闪烁 |
每行 animation-delay: i * 120ms | min(i, 6) * 40ms | 最后一行在 300ms 内到达 |
| 抽屉滑入后淡出 | 它按来路滑回退出 | 方向就是解释 |
拖拽释放使用固定的 ease-out | 使用带有释放速度的弹簧 | 手部动量继续 |
在 pointermove 中使用 setY(y) | el.style.transform = ... | 避免每帧重新渲染 |
结束前
搜索差异,然后验证。
| 搜索内容 | 修复 |
|---|---|
transition: all、* 上的 will-change、ease-in | 命名属性、移除、--ease-out |
弹出框上的 scale(0)、transform-origin: center | 0.95、触发起源点(对话框保持居中) |
在切换的类上使用 @keyframes | 使用过渡 |
| 时长超过 tokens.md 中对应行,或数字不在 tokens.md 中 | 使用该用途的标记 |
keydown 处理程序或面板上的动效 | 移除它 |
过渡中的 width、height、top、left、box-shadow | 使用 transform、opacity 或方案 |
没有 prefers-reduced-motion 路径 | 添加它 |
在运行的应用中验证:以 4 倍速播放(DevTools 动画面板,或乘以时长),连续快速触发任何开关,在开启减少动效的情况下测试,并将 CPU 降速 4 倍。说明哪些测试你无法运行。