mcpField
M
motion-design

motion-design

github.com/arnavpuri设计与 UI17121 次安装5 周前更新
代码仓库

关于 motion-design

为 Agent 提供 UI 动效设计指导,帮助在改善交互反馈的同时兼顾性能与无障碍。内容包括过渡、微交互、滚动动画、加载状态及减少动态效果支持。

主要功能

  1. 01动画时长与缓动曲线指南
  2. 02按钮、开关及页面过渡示例
  3. 03滚动触发与加载动画模式
  4. 04减少动态效果的适配方法
  5. 05动画性能优化与验证

适用场景

  1. 01为按钮和开关添加交互反馈
  2. 02设计页面进入与列表渐显动效
  3. 03构建加载占位和指示动画
  4. 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 动画原则

三条规则
  1. 每个动画都必须有目的:引导注意力、提供反馈、展示关系或传达状态变化。
  2. 越快几乎总是越好:UI 动画时长应为 100-500ms。
  3. 只为发生变化的内容添加动画:只对实际发生变化的属性添加动画。
时长指南
操作时长
按钮悬停/按下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);

快速参考

  1. 每个动画都应有明确目的:反馈、引导或状态变化。
  2. 保持时长较短:大多数 UI 交互为 100-400ms。
  3. 进入时使用 ease-out,退出时使用 ease-in,移动时使用 ease-in-out。
  4. 为获得最佳性能,只对 transform 和 opacity 设置动画。
  5. 始终实现 prefers-reduced-motion。
  6. 内容加载时使用骨架屏加载器,执行操作时使用旋转加载指示器。
  7. 列表动画以 40-80ms 的间隔错开,总时长低于 600ms。
  8. 使用 animation: ... both 来保持结束状态。
  9. 弹簧缓动(值 >1 的 cubic-bezier)能增添个性。
  10. 在低端设备上测试——动画抖动比没有动画更糟。

验证动态效果

无法仅凭代码判断动态效果。交付前请检查:

  1. 帧:使用 Playwright 捕获元素在动画过程中和静止时的状态(例如,触发后立即执行 page.screenshot(),再在 await page.waitForTimeout(duration + 50) 后截图),并使用 Read 查看。确认起始和结束状态正确,且没有元素跳动或溢出。
  2. 减少动态效果:使用 await page.emulateMedia({ reducedMotion: 'reduce' })(或 browser.newContext({ reducedMotion: 'reduce' }))重新运行,并确认内容立即可见且保持静止。
  3. 性能:在 DevTools Performance(或 chrome://tracing)中查找长帧以及动画期间的“Layout”;如果存在,请改用 transform/opacity。

评论

支持 Markdown