Spree 动画质量标准(Animation Standards):一份可直接复用的 UI 动效评审与实现规范
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
本文整理自 Spree 仓库中 .agents/skills/review-animations/STANDARDS.md 与配套的 SKILL.md,它们沉淀了 Emil Kowalski(animations.dev)的设计工程哲学,并结合 packages/dashboard-ui 的实际实现给出了仓库内可对照的落点。读完你可以获得一套完整的动效决策树:什么该动、动多久、用哪条曲线、从哪里缩放、如何保证可中断、如何在 GPU 上跑、以及如何在评审中给出可引用的精确数值,而不是"大概、差不多"。
一、总览:这份规范解决什么问题
Spree 是一个面向 B2B、Marketplace 与企业的开源电商平台,其管理端(dashboard-ui)承载着高频使用的表格、弹窗、下拉、抽屉等界面。这类界面最怕的不是"没有动画",而是动画让日常操作变慢、变飘、掉帧。因此仓库在 .agents/skills/review-animations/SKILL.md 中定义了一个专门的动画评审技能,并将全部数值标准集中在 STANDARDS.md 中,供评审时逐条引用。
规范的核心立场是:评审者的偏见应该朝向"感觉对"的动效,而不是"能跑"的动效。一个过渡即使逻辑上"工作正常",但只要感觉迟滞、从错误的原点落地、触发频率过高或掉帧,它就是一个回归(regression),而不是通过。默认立场是标记问题(default to flagging),批准是需要争取的(approval is earned)。
整套标准围绕十条不可妥协的标准展开(Justified motion、Frequency-appropriate、Responsive easing、Sub-300ms UI、Origin & physical correctness、Interruptibility、GPU-only properties、Accessibility、Asymmetric enter/exit、Cohesion),下文将逐一展开其背后的精确数值与曲线。
二、先回答第一个问题:它该不该动?
规范的起点不是"怎么动",而是"要不要动"。因为动效的成本是隐性的:每次播放都占用用户的注意力与设备的渲染时间,对高频元素尤其致命。
频率决策表
| 频率 | 决策 |
|---|---|
| 100+ 次/天(键盘快捷键、命令面板开关) | 永远不加动画 |
| 几十次/天(悬停效果、列表导航) | 移除或大幅削减 |
| 偶尔(模态框、抽屉、Toast) | 标准动画 |
| 罕见/首次(引导、反馈、庆祝) | 可以增加趣味性 |
规范特别强调:永远不要为键盘发起的操作加动画——这类操作每天重复数百次,动画会让它们显得缓慢且脱节。文中举的例子是 Raycast:它没有开关动画,这恰恰是正确的选择。
什么才是动效的正当理由
判定"该不该动"时,以下才是有效用途:
- 空间一致性(spatial consistency)
- 状态指示(state indication)
- 解释(explanation)
- 反馈(feedback)
- 防止突兀变化(preventing jarring change)
"看起来很酷"不是理由——尤其当它出现在高频元素上时。这对应 SKILL.md 的第 1 条标准(Justified motion)与第 2 条标准(Frequency-appropriate)。
三、缓动曲线:每个场景的精确取值
决策顺序
| 场景 | 缓动 | 理由 |
|---|---|---|
| 进入或退出 | ease-out | 起步快,感觉响应迅速 |
| 屏幕上移动/形变 | ease-in-out | 起止都平滑 |
| 悬停/颜色变化 | ease | 柔和渐进 |
| 持续运动(跑马灯、进度条) | linear | 匀速 |
| 默认 | ease-out | 兜底选择 |
红线:UI 上永远不要用ease-in
ease-in起步慢,恰恰推迟了用户最关注的瞬间。规范给出一个直观的对比结论:200ms 的ease-out比 200ms 的ease-in感觉更快。所以在 SKILL.md 的升级触发列表(escalation triggers)中,"任何 UI 交互上的ease-in"是见到就要硬性标记(flag on sight)的条目。
内置缓动太弱,用强自定义曲线
CSS 内置的ease/ease-out对"有意识的动效"来说太弱,规范要求使用更强的自定义曲线:
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* 用于 UI 的强 ease-out */ --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* 用于屏上移动的强 ease-in-out */ --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS 风格抽屉曲线(源自 Ionic) */Spree 的 packages/dashboard-ui/src/styles.css 正是这套标准的直接落地:它把--ease-out: cubic-bezier(0.23, 1, 0.32, 1)与--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1)定义为主题令牌,并注明"覆盖 Tailwind 内置的ease-out,让应用里每一个ease-out工具类都吃到这条曲线——库存曲线太弱,在比颜色变化更大的动效上读不出'有意为之'"。这印证了规范中"不要手搓曲线"的建议:仓库通过 CSS 变量实现全局统一,而不是各处手写。
四、时长预算:UI 动画控制在 300ms 以内
| 元素 | 时长 |
|---|---|
| 按钮按压反馈 | 100–160ms |
| Tooltip、小弹层 | 125–200ms |
| 下拉、选择器 | 150–250ms |
| 模态框、抽屉 | 200–500ms |
| 营销/解释性动效 | 可以更长 |
核心规则:UI 动画保持在 300ms 以下。一个 180ms 的下拉比 400ms 的下拉感觉更跟手;更快的 spinner 会让加载感觉更快(实际时间相同);第一次之后的 tooltip 立即出现(跳过延迟+动画)会让工具栏整体感觉更快。
Spree 侧同样可见这套时长的影子:dashboard-ui 的按钮反馈使用transition: background-color 150ms ease-out(见 styles.css),popover 使用duration-100 ease-out(见 popover.tsx),都在 300ms 预算之内。
五、物理感:从正确的原点、正确的比例出现
绝不要scale(0)
现实世界没有任何东西是从无到有的,所以入场动画永远不要从scale(0)开始,而应从scale(0.9–0.97)+opacity: 0起步。
原点评判:从触发器缩放,而不是中心
.popover { transform-origin: var(--transform-origin); } /* Base UI 的惯用法 */弹层(popover)应当从触发它的那个元素(anchor/trigger)缩放出来,而不是从自身中心。模态框是例外——它们出现在视口中央,保持transform-origin: center即可。
Spree 的实现完全遵循这一点:dashboard-ui 的 popover 明确写了origin-(--transform-origin)(由 Base UI 的 Positioner 发布),配合data-[starting-style]:scale-95做入场(见 popover.tsx)——即从 0.95 而非 0 起步。同一模式也应用在仓库的 combobox.tsx、context-menu.tsx、dropdown-menu.tsx、tooltip.tsx 等触发器锚定组件上。
按钮按压反馈
对任何可按压的元素:
.button:active { transform: scale(0.97); /* 微妙范围 0.95–0.98 */ transition: transform 160ms ease-out; }六、弹簧(Springs):什么时候用物理模拟
弹簧动效之所以自然,是因为它模拟物理:没有固定时长,靠参数收敛。适用于:带惯性的拖拽、需要"活物感"的元素(如 Dynamic Island)、可中断手势、装饰性的鼠标跟随。
// Apple 风格(更易推理)——推荐 { type: "spring", duration: 0.5, bounce: 0.2 } // 传统物理(更精细的控制) { type: "spring", mass: 1, stiffness: 100, damping: 10 }要点:
- 弹跳幅度保持微妙(0.1–0.3);大多数 UI 里避免弹跳,保留给"拖拽关闭"和俏皮交互。
- 弹簧在被打断时保持速度(keyframes 则会从零重启),因此适合用户可能中途反向的手势。
- 鼠标交互应当用
useSpring做插值,而不是把值直接绑到鼠标位置(直接绑定=生硬、无动量);仅限装饰性动效。
七、可中断性:transitions 优于 keyframes
CSStransition 可以中途被打断并重新定向到新目标;而keyframes 被打断会从零重新开始。因此,任何可能被快速反复触发的动效(不断新增的 toast、开关切换)都应该用 transition:
/* 可中断——适合动态 UI */ .toast { transition: transform 400ms ease; } /* 不可中断——动态 UI 中应避免 */ @keyframes slideIn { from { transform: translateY(100%); } to { transform: translateY(0); } }纯 CSS 入场:@starting-style
不需要 JS 的入场动效可以用@starting-style:
.toast { opacity: 1; transform: translateY(0); transition: opacity 400ms ease, transform 400ms ease; @starting-style { opacity: 0; transform: translateY(100%); } }旧浏览器回退方案:useEffect(() => setMounted(true), [])+data-mounted属性。
八、不对称时序:用户在决策处放慢,系统响应要快
用户在做决定的地方放慢,系统响应的瞬间加快。
.overlay { transition: clip-path 200ms ease-out; } /* 释放:快 */ .button:active .overlay { transition: clip-path 2s linear; } /* 按压:慢而笃定 */这对应 SKILL.md 的第 9 条标准(Asymmetric enter/exit):按压-释放或按住型交互若使用对称时序,就是一个 finding。
九、性能:只在 GPU 上动transform和opacity
只动画transform与opacity
只有这两个属性会跳过 layout/paint 直接在 GPU 上运行。padding/margin/height/width/top/left会触发全部三步渲染流程,是性能红线。
不要在父元素上用 CSS 变量驱动子元素 transform
在父元素上改 CSS 变量会触发所有子元素的样式重算:
element.style.setProperty('--swipe-amount', `${d}px`); // 坏:所有子元素都重算 element.style.transform = `translateY(${d}px)`; // 好:只影响这一个元素Framer Motion 简写不走硬件加速
x/y/scale简写通过 rAF 在主线程运行,负载下会掉帧;完整 transform 字符串才走硬件加速:
<motion.div animate={{ x: 100 }} /> // 负载下掉帧 <motion.div animate={{ transform: "translateX(100px)" }} /> // 硬件加速CSS 动画在负载下优于 JS
CSS 动画脱离主线程运行;rAF 驱动的 JS 动画在浏览器加载/执行脚本/绘制时容易卡顿。预定的运动用 CSS,动态/可中断的运动用 JS。
WAAPI:用 CSS 的性能拿到 JS 的控制力
Web Animations API 硬件加速、可中断、无需任何库:
element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }], { duration: 1000, fill: 'forwards', easing: 'cubic-bezier(0.77, 0, 0.175, 1)' });十、Transforms 与 clip-path 的进阶用法
translate百分比相对元素自身尺寸:translateY(100%)无论如何都移动自身一个高度(Sonner/Vaul 定位 toast/抽屉正是这么做的)。优先于写死的 px。scale()会连带缩放子元素(字体、图标、内容)——对按压反馈这是特性而非缺陷。- 3D:
rotateX/Y+transform-style: preserve-3d无需 JS 即可做出深度/轨道/翻转。 clip-path: inset(t r b l)是强大的动画工具,每个值从对应方向"吃入"。用途:滚动显现(inset(0 0 100% 0)→inset(0 0 0 0))、长按删除遮罩、无缝的标签页颜色过渡(复制一份并裁剪激活副本)、对比滑块。
十一、手势与拖拽的工程细节
- 动量关闭:不要要求越过距离阈值——计算速度(
Math.abs(distance)/elapsedMs),速度> ~0.11即关闭。一个轻拂就够了。 - 边界阻尼:拖过自然边界后,越远移动越少(真实物体在停下前会减速)。
- 指针捕获:拖拽开始后捕获指针,离开边界仍能继续。
- 多点触控保护:拖拽开始后忽略额外触点(
if (isDragging) return),防止跳动。 - 用摩擦代替硬停:允许带递增阻力的过拖,而不是一堵无形的墙。
十二、遮罩不完美的交叉淡化
当交叉淡化在调优缓动和时长后仍露出两个重叠状态时,在过渡期间加一点filter: blur(2px),把两个状态融成一次感知上的单一变换。blur 保持 < 20px——过重的模糊很贵,尤其在 Safari 上。
十三、Stagger:组入场错峰
组入场使用 stagger,每项间隔 30–80ms,更长的延迟会感觉拖沓。Stagger 是装饰性的——播放期间绝不要阻塞交互。
.item { opacity: 0; transform: translateY(8px); animation: fadeIn 300ms ease-out forwards; } .item:nth-child(2) { animation-delay: 50ms; } .item:nth-child(3) { animation-delay: 100ms; } @keyframes fadeIn { to { opacity: 1; transform: translateY(0); } }十四、无障碍:reduced-motion 是"更少更柔",不是"零"
@media (prefers-reduced-motion: reduce) { .element { animation: fade 0.2s ease; } /* 保留 opacity/color,去掉 transform 运动 */ } @media (hover: hover) and (pointer: fine) { .element:hover { transform: scale(1.05); } /* 悬停动效加门控——触屏点击会误触发悬停 */ }const reduce = useReducedMotion(); const closedX = reduce ? 0 : '-100%';关键理解:reduced-motion 意味着更少、更柔和的动画,而不是零动画——保留帮助理解的过渡,移除位移/位置变化。悬停动画必须被@media (hover: hover) and (pointer: fine)门控,否则触屏设备在点按时会误触发假悬停。
Spree 的 dashboard-ui 在仓库内实现了完整的双层保障:
- CSS 层:styles.css 在
prefers-reduced-motion: reduce下把transition-property收窄到color, background-color, border-color, box-shadow, opacity, fill, stroke,时长压到100ms,并把循环装饰动画锁在首帧(animation-duration: 0.01ms; animation-iteration-count: 1)。 - JS 层:use-prefers-reduced-motion.ts 实时监听
window.matchMedia('(prefers-reduced-motion: reduce)')。其注释点出了一个易被忽视的细节:在 CSS 里单纯压制 transition 会把滑动变成跳变,与用户的设置初衷相反;所以组件需要在 JS 里读到偏好后直接放弃位移、保持原位。Hook 初始值为false,保证 SSR 渲染与客户端首帧一致,挂载后通过 effect 再同步一次。
十五、调试:感觉拿不准时的评审建议
当动效"感觉"不对但说不清时,规范建议在评审中推荐以下手段:
- 慢放:把时长放大 2–5 倍,或使用 DevTools 动画检查器。检查颜色交叉淡化是否干净、缓动是否突然中止、
transform-origin是否正确、协同属性是否保持同步。 - 逐帧检查:Chrome DevTools 的 Animations 面板可以暴露协同属性之间的时序漂移。
- 真机测试手势(抽屉、滑动):连上手机,用 IP 访问 dev server,使用 Safari 远程调试。
- 隔天再看:开发中看不见的瑕疵,往往过一天就暴露了。
十六、一致性:动效要匹配组件的性格
动效要与组件的性格匹配:俏皮的组件可以弹得更欢,专业的 dashboard 应该干脆利落。规范以 Sonner 为例——它的观感之所以对,正是因为缓动、时长、设计与名字彼此和谐:稍慢、用ease而非ease-out,显得优雅。列表中进出的"opacity + height"组合没有公式,只能试错调整到感觉对为止。
十七、把标准变成评审动作:SKILL.md 的可操作流程
SKILL.md 把上面的标准组织成一套可复用的评审 SOP:
硬性升级触发(见到即标记)
transition: all(无界属性动画)scale(0)或纯淡入且无初始 transform 的入场- 任何 UI 交互上的
ease-in;刻意动画上用弱内置缓动 - 键盘快捷键、命令面板开关或 100+/天操作上的动画
- 超过 300ms 且无说明理由的 UI 动画
- 触发器锚定弹层上使用
transform-origin: center - toast/开关等快速触发元素上使用 keyframes
- 动画布局属性(
width/height/margin/padding/top/left) - 页面繁忙时仍使用 Framer Motion
x/y/scale - 在父元素上更新 CSS 变量驱动子元素 transform(样式重算风暴)
- 位移动效缺少
prefers-reduced-motion处理 - 未门控的
:hover动效 - 按压-释放/按住交互使用对称进出时序
- 应该用 30–80ms stagger 却"一次全部出现"的入场
修复优先级层级
- 删除动画(高频/无目的/键盘触发)
- 削减——更短时长、更小位移、更少动画属性
- 修缓动——
ease-in→ease-out/自定义曲线 - 修原点/物理感——纠正
transform-origin;scale(0)→scale(0.95)+opacity - 使其可中断——keyframes → transitions,或手势动效用弹簧
- 移到 GPU——布局属性 →
transform/opacity;简写 → 完整 transform;程序化 CSS 用 WAAPI - 不对称时序——放慢决策阶段,快放系统响应
- 打磨——用 blur 遮罩交叉淡化、组入场 stagger、
@starting-style入场、弹簧给"活物感" - 无障碍与一致性——补 reduced-motion + 悬停门控;调成匹配组件性格
评审输出格式
评审必须输出两部分:先是一张 findings 表(每行一个 issue,用 Before/After/Why 三列,绝不使用 "Before:/After:" 列表);再是按影响层级排序的结论(Feel-breaking regressions → Missed simplifications → Performance → Interruptibility & timing → Origin, physicality & cohesion → Accessibility),最后给出明确裁决:
- Block(阻止合并)——任何破坏感觉的回归、键盘/高频操作上的动画、UI 上的
scale(0)/ease-in、或有简单 GPU 修复却没用上的非 GPU 动画。 - Approve(通过)——无破坏感觉的回归、无明显该删的动效、时长与缓动在边界内、需要处可中断、尊重 reduced-motion。
所有结论要具体并引用file:line;需要数值(曲线、时长、弹簧配置)时,从 STANDARDS.md 取精确值,而不是近似估算。
十八、在 Spree 仓库中继续深入
如果你正在为 Spree 的 dashboard 编写或评审动效,可以按以下路径对照落地:
- 数值标准全集:.agents/skills/review-animations/STANDARDS.md
- 评审方法与十条标准:.agents/skills/review-animations/SKILL.md
- 全局缓动令牌(
--ease-out/--ease-in-out自定义曲线):packages/dashboard-ui/src/styles.css - 触发器锚定弹层的
transform-origin实现:popover.tsx,以及同模式的 tooltip.tsx、dropdown-menu.tsx、combobox.tsx、context-menu.tsx - reduced-motion 的 JS 侧实现:packages/dashboard-ui/src/hooks/use-prefers-reduced-motion.ts
- reduced-motion 的 CSS 侧兜底:packages/dashboard-ui/src/styles.css
这套规范的价值在于:把"感觉对不对"这种主观判断,翻译成一组可引用、可复现、可自动检查的数值与规则。无论是写代码前预判、写完后自查,还是作为 reviewer 逐条对照,它都能让动效决策从"我觉得"升级为"标准如此"。
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考