关于 motion-design
为 Agent 提供 UI 动效设计指导,帮助在改善交互反馈的同时兼顾性能与无障碍。内容包括过渡、微交互、滚动动画、加载状态及减少动态效果支持。
主要功能
- 01动画时长与缓动曲线指南
- 02按钮、开关及页面过渡示例
- 03滚动触发与加载动画模式
- 04减少动态效果的适配方法
- 05动画性能优化与验证
适用场景
- 01为按钮和开关添加交互反馈
- 02设计页面进入与列表渐显动效
- 03构建加载占位和指示动画
- 04检查减少动态效果与动画性能
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ arnavpuri/ designskills --skill motion-design安装后在 Claude Code 中输入/motion-design确认已加载。
详细介绍
动效设计
添加有目的的动画和微交互,提升用户体验,同时不影响性能。
前置条件
- 阅读
.agents/design-context.md(参见design-context):风格原型决定动效特征(Minimal/Corporate = 时长短、不使用过冲;Playful = 可以使用弹簧效果;Luxurious = 较慢、平滑的缓动)。 - 如果存在设计系统,请使用其中的过渡 token。下面的 token 名称(
--duration-*、--ease-*)与 design-system 技能中定义的名称相同。 - 始终遵循
prefers-reduced-motion。
第 1 步:UI 动画原则
三条规则
- 每个动画都必须有目的:引导注意力、提供反馈、展示关系或传达状态变化。
- 越快几乎总是越好:UI 动画时长应为 100-500ms。
- 只为发生变化的内容添加动画:只对实际发生变化的属性添加动画。
时长指南
| 操作 | 时长 |
|---|---|
| 按钮悬停/按下 | 100-150ms |
| 切换开关 | 200ms |
| 下拉菜单/菜单打开 | 200-250ms |
| 模态框/对话框出现 | 250-300ms |
| 页面过渡 | 300-400ms |
| 展开/折叠 | 250-350ms |
| 加载旋转动画周期 | 700-1200ms |
退出动画应比对应的进入动画短约 20-30%(例如,模态框进入用时 250ms,退出用时 200ms)。移动距离较大或较长的动画需要更多时间;移动端的动画时长通常短于桌面端/平板端。
缓动函数
:root {
--duration-fast: 100ms; --duration-normal: 200ms; --duration-slow: 300ms; --duration-slower: 500ms;
--ease-out: cubic-bezier(0, 0, 0.2, 1); /* Decelerate: ENTER */
--ease-in: cubic-bezier(0.4, 0, 1, 1); /* Accelerate: EXIT */
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); /* Standard: MOVE */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Bouncy overshoot */
--ease-bounce: cubic-bezier(0.68, -0.55, 0.27, 1.55); /* Strong bounce */
}
进入时使用 ease-out,退出时使用 ease-in,移动时使用 ease-in-out,趣味反馈时使用弹簧效果。
第 2 步:微交互模式
按钮按下
.btn {
transition: transform 0.1s var(--ease-out), background-color 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
}
.btn:hover { box-shadow: 0 4px 12px oklch(from var(--interactive-primary) l c h / 0.3); }
.btn:active { transform: scale(0.97); }
切换开关
.toggle-track { width: 44px; height: 24px; background: var(--gray-300); border-radius: 12px; padding: 2px; cursor: pointer; transition: background-color 0.2s var(--ease-out); }
.toggle-thumb { width: 20px; height: 20px; background: white; border-radius: 50%; transition: transform 0.2s var(--ease-spring); }
.toggle-input:checked + .toggle-track { background: var(--brand-500); }
.toggle-input:checked + .toggle-track .toggle-thumb { transform: translateX(20px); }
.toggle-input:focus-visible + .toggle-track { outline: 2px solid var(--interactive-primary); outline-offset: 2px; }
.toggle-input 必须是 <label> 内真实的 <input type="checkbox" role="switch">,并进行视觉隐藏(不能使用 display: none),以便键盘和屏幕阅读器仍可操作。
点赞/心形动画
.like-btn.is-liked .heart-icon { animation: like-pop 0.4s var(--ease-spring); color: #ef4444; fill: currentColor; }
@keyframes like-pop { 0% { transform: scale(1); } 30% { transform: scale(1.3); } 60% { transform: scale(0.9); } 100% { transform: scale(1); } }
第 3 步:页面过渡动画
上滑并淡入
.page-enter { animation: slideUpFade 0.4s var(--ease-out) both; }
@keyframes slideUpFade {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
View Transitions API(现代方案)
/* Cross-document (MPA, same origin): opt in on BOTH pages */
@view-transition { navigation: auto; }
::view-transition-old(root) { animation: vt-fade-out 0.2s var(--ease-in) both; }
::view-transition-new(root) { animation: vt-fade-in 0.3s var(--ease-out) both; }
@keyframes vt-fade-out { to { opacity: 0; } }
@keyframes vt-fade-in { from { opacity: 0; } }
.hero-image { view-transition-name: hero; } /* must be unique on the page */
@media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }
对于 SPA,请包装 DOM 更新:document.startViewTransition ? document.startViewTransition(update) : update();。不支持该功能的浏览器会直接跳过动画。
第 4 步:滚动触发动画
仅使用 CSS 和 animation-timeline
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.scroll-reveal { animation: reveal linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; }
}
}
@keyframes reveal { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } }
animation-timeline 必须放在 animation 简写属性之后(该简写属性会重置它)。不支持滚动驱动动画时,内容仍会完全可见。
Intersection Observer 方案
/* Hide only when JS is running, so content is never lost without JS */
.js .reveal { opacity: 0; transform: translateY(30px); transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out); }
.js .reveal.is-visible { opacity: 1; transform: translateY(0); }
document.documentElement.classList.add('js');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) { entry.target.classList.add('is-visible'); observer.unobserve(entry.target); }
});
}, { threshold: 0.1 });
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
第 5 步:加载动画
骨架屏加载器
.skeleton { background: var(--gray-200); border-radius: var(--radius-md); position: relative; overflow: hidden; }
.skeleton::after {
content: ''; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.4), transparent);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
.skeleton-text { height: 1em; margin-bottom: 0.75em; }
.skeleton-text:last-child { width: 60%; }
.skeleton-avatar { width: 48px; height: 48px; border-radius: 50%; }
旋转加载器
.spinner { width: 24px; height: 24px; border: 3px solid var(--gray-200); border-top-color: var(--brand-500); border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
三点弹跳
.dots-loader { display: flex; gap: 4px; }
.dots-loader span { width: 8px; height: 8px; background: var(--brand-500); border-radius: 50%; animation: dot-bounce 1.2s ease-in-out infinite; }
.dots-loader span:nth-child(2) { animation-delay: 0.15s; }
.dots-loader span:nth-child(3) { animation-delay: 0.3s; }
@keyframes dot-bounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
第 6 步:CSS 动画技巧
过渡与动画的区别
transition:用于状态变化(悬停、聚焦、类切换)。从 A 到 B。animation:用于多步序列、连续效果或进入动画。
动画填充模式
| 值 | 行为 |
|---|---|
| none | 动画结束后恢复到原始状态 |
| forwards | 保持在最后一个关键帧 |
| backwards | 延迟期间应用第一个关键帧 |
| both | 合并 forwards + backwards。通常应使用此值 |
第 7 步:使用 CSS 实现弹簧物理效果
:root {
--spring-gentle: cubic-bezier(0.34, 1.2, 0.64, 1); /* Slight overshoot */
--spring-medium: cubic-bezier(0.34, 1.56, 0.64, 1); /* Noticeable bounce */
--spring-strong: cubic-bezier(0.22, 1.8, 0.36, 1); /* Dramatic bounce */
--spring-snappy: cubic-bezier(0.075, 0.82, 0.165, 1); /* Not a spring: fast ease-out (easeOutCirc), no overshoot */
}
要实现多次弹跳,请使用关键帧:
@keyframes spring-bounce {
0% { transform: scale(0.8); } 40% { transform: scale(1.08); }
60% { transform: scale(0.97); } 80% { transform: scale(1.02); } 100% { transform: scale(1); }
}
第 8 步:减少动态效果
全局减少动态效果
@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;
}
}
首选做法:用非动态过渡替代动态效果(例如,使用透明度变化而不是变换)。上面的全局规则是一道安全网:它也会让旋转加载指示器只转一圈就停止,因此请为加载指示器提供不旋转的替代方案(脉冲式透明度或“正在加载……”文本)。
对于由 JS 驱动的动画(Web Animations API、GSAP、Framer Motion),请检查偏好设置并监听其变化:
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const duration = () => (reduce.matches ? 0 : 300);
reduce.addEventListener('change', () => { /* re-read duration(), stop autoplaying loops */ });
另外:每秒闪烁不得超过 3 次(WCAG 2.3.1),任何持续超过 5 秒的自动播放动态效果都需要提供暂停控件(WCAG 2.2.2)。
步骤 9:性能优化
黄金法则
只对 transform 和 opacity 设置动画。它们由 GPU 合成,不会触发布局或绘制。
属性开销
| 属性 | 布局 | 绘制 | 合成 | 性能 |
|---|---|---|---|---|
| transform | 否 | 否 | 是 | 极佳 |
| opacity | 否 | 否 | 是 | 极佳 |
| filter | 否 | 经常 | 不定 | 良好(大面积模糊开销较高) |
| box-shadow | 否 | 是 | 否 | 中等 |
| width/height | 是 | 是 | 否 | 较差 |
| top/left | 是 | 是 | 否 | 较差 |
谨慎使用 will-change——只用于即将开始动画的元素,并在动画结束后移除。切勿永久应用于多个元素。
步骤 10:为列表设置错开动画
使用自定义属性实现动态错开
.stagger-list > * {
animation: stagger-in 0.4s var(--ease-out) both;
animation-delay: calc(var(--index, 0) * 60ms);
}
@keyframes stagger-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
<ul class="stagger-list">
<li style="--index: 0">Item 1</li>
<li style="--index: 1">Item 2</li>
<li style="--index: 2">Item 3</li>
</ul>
错开规则
- 错开总时长不应超过 600ms
- 每项延迟:项与项之间间隔 40-80ms
- 8-10 项之后限制延迟(它们可以同时出现):
animation-delay: calc(min(var(--index, 0), 8) * 60ms);
快速参考
- 每个动画都应有明确目的:反馈、引导或状态变化。
- 保持时长较短:大多数 UI 交互为 100-400ms。
- 进入时使用 ease-out,退出时使用 ease-in,移动时使用 ease-in-out。
- 为获得最佳性能,只对
transform和opacity设置动画。 - 始终实现
prefers-reduced-motion。 - 内容加载时使用骨架屏加载器,执行操作时使用旋转加载指示器。
- 列表动画以 40-80ms 的间隔错开,总时长低于 600ms。
- 使用
animation: ... both来保持结束状态。 - 弹簧缓动(值 >1 的
cubic-bezier)能增添个性。 - 在低端设备上测试——动画抖动比没有动画更糟。
验证动态效果
无法仅凭代码判断动态效果。交付前请检查:
- 帧:使用 Playwright 捕获元素在动画过程中和静止时的状态(例如,触发后立即执行
page.screenshot(),再在await page.waitForTimeout(duration + 50)后截图),并使用 Read 查看。确认起始和结束状态正确,且没有元素跳动或溢出。 - 减少动态效果:使用
await page.emulateMedia({ reducedMotion: 'reduce' })(或browser.newContext({ reducedMotion: 'reduce' }))重新运行,并确认内容立即可见且保持静止。 - 性能:在 DevTools Performance(或
chrome://tracing)中查找长帧以及动画期间的“Layout”;如果存在,请改用transform/opacity。