动效设计:让AI交互更自然

引言

动效(Motion Design)是界面的"语气"——恰到好处的动效能让交互更流畅、反馈更即时、体验更自然;过度的动效则成为干扰和负担。AI 产品尤其依赖动效:流式打字效果传递"AI 正在思考"的实时感,加载动画缓解等待焦虑,状态过渡帮助用户理解界面变化。本文从动效原则到 AI 场景的具体实现,提供完整的实战指南。

一、动效设计原则

1.1 迪士尼十二原则在 UI 中的应用

原则 1: 缓入缓出(Ease In/Out)
  UI 应用:元素不应以匀速运动
  推荐:ease-out(进入)/ ease-in(离开)
  CSS:transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);

原则 2: 预备动作(Anticipation)
  UI 应用:操作前的视觉预告
  示例:按钮按下时轻微缩小,松开后弹回

原则 3: 夸张(Exaggeration)
  UI 应用:微妙夸张让动效可感知
  示例:卡片悬停时放大 2-5%(非 50%)

原则 4: 次要动作(Secondary Action)
  UI 应用:主动作伴随次要视觉反馈
  示例:消息发送时,气泡飞出 + 输入框清空 + 时间戳淡入

原则 5: 时间控制(Timing)
  UI 应用:动效时长需匹配操作预期
  微交互:100-200ms
  页面转场:200-400ms
  复杂动画:400-700ms

1.2 功能性动效 vs 装饰性动效

功能性动效(推荐):
  - 状态过渡:组件从状态 A 到状态 B 的视觉桥梁
  - 反馈确认:操作后的即时视觉回应
  - 引导注意力:将用户视线引向新内容
  - 空间关系:揭示界面元素的层级和位置关系
  - 等待缓解:加载过程中减少焦虑

装饰性动效(谨慎使用):
  - 背景粒子效果
  - 自动播放的复杂动画
  - 无功能目的的悬停特效
  - 启动画面的品牌动画(首次除外)

判断标准:
  删掉这个动效后,用户的理解或体验会下降吗?
  → 会下降:功能性动效(保留)
  → 不会下降:装饰性动效(可删)

1.3 时间与缓动函数

推荐时长:

微交互(Micro):
  按钮状态变化:100-150ms
  开关切换:200ms
  Tooltip 显示:150ms
  颜色/透明度变化:100-200ms

中等交互(Medium):
  下拉菜单展开:200-300ms
  侧边栏滑出:250-350ms
  模态框出现:200-300ms
  卡片展开:250-350ms

宏观交互(Macro):
  页面转场:300-500ms
  复杂布局变化:400-600ms
  首屏加载动画:500-1000ms

推荐缓动函数:

标准(进入 + 离开):
  cubic-bezier(0.4, 0, 0.2, 1)  -- Material Design standard

仅进入(元素出现):
  cubic-bezier(0, 0, 0.2, 1)    -- Decelerate

仅离开(元素消失):
  cubic-bezier(0.4, 0, 1, 1)    -- Accelerate

弹性(强调/趣味):
  cubic-bezier(0.34, 1.56, 0.64, 1)  -- Overshoot

CSS 变量统一管理:
  :root {
    --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
    --ease-enter: cubic-bezier(0, 0, 0.2, 1);
    --ease-exit: cubic-bezier(0.4, 0, 1, 1);
    --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
    --duration-fast: 150ms;
    --duration-normal: 250ms;
    --duration-slow: 400ms;
  }

二、AI 产品核心动效

2.1 AI 思考/加载状态

/* 方案 1: 脉动点(ChatGPT 风格) */
.thinking-dots {
  display: flex;
  gap: 4px;
  padding: 12px 16px;
}

.thinking-dots span {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #6B7280;
  animation: dot-pulse 1.4s ease-in-out infinite;
}

.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }

@keyframes dot-pulse {
  0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
  40% { transform: scale(1); opacity: 1; }
}

/* 方案 2: 骨架屏脉冲 */
.skeleton-pulse {
  background: linear-gradient(
    90deg,
    #E5E7EB 25%,
    #F3F4F6 50%,
    #E5E7EB 75%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s ease-in-out infinite;
  border-radius: 4px;
}

@keyframes skeleton-shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

/* 方案 3: 品牌色呼吸灯 */
.breathing-glow {
  box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
  animation: breathing 2s ease-in-out infinite;
}

@keyframes breathing {
  0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
  50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0); }
}

2.2 流式打字效果

/* AI 回复的逐字显示 */
.streaming-text {
  /* 光标闪烁 */
  border-right: 2px solid #3B82F6;
  animation: cursor-blink 1s step-end infinite;
  padding-right: 2px;
}

@keyframes cursor-blink {
  0%, 100% { border-color: #3B82F6; }
  50% { border-color: transparent; }
}

/* 流式完成后移除光标 */
.streaming-text.complete {
  border-right: none;
  animation: none;
}
// 流式文本渲染器(带速度控制)
class StreamRenderer {
  constructor(element, options = {}) {
    this.el = element;
    this.speed = options.speed || 30; // ms per character
    this.queue = [];
    this.isRendering = false;
  }

  append(text) {
    this.queue.push(...text.split(''));
    if (!this.isRendering) {
      this.render();
    }
  }

  async render() {
    this.isRendering = true;
    this.el.classList.add('streaming-text');

    while (this.queue.length > 0) {
      const char = this.queue.shift();
      this.el.textContent += char;

      // 标点符号后稍作停顿
      const pause = /[.!?。!?]/.test(char) ? this.speed * 3 : this.speed;
      await new Promise(r => setTimeout(r, pause));
    }

    this.el.classList.remove('streaming-text');
    this.el.classList.add('complete');
    this.isRendering = false;
  }
}

2.3 消息气泡动效

/* 新消息进入动效 */
.message-enter {
  animation: message-slide-in 300ms var(--ease-enter) forwards;
}

@keyframes message-slide-in {
  from {
    opacity: 0;
    transform: translateY(20px) scale(0.95);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

/* 用户消息:从右侧滑入 */
.user-message.message-enter {
  animation: user-msg-enter 250ms var(--ease-enter) forwards;
}

@keyframes user-msg-enter {
  from {
    opacity: 0;
    transform: translateX(30px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

/* AI 消息:从左侧淡入 */
.ai-message.message-enter {
  animation: ai-msg-enter 300ms var(--ease-enter) forwards;
}

@keyframes ai-msg-enter {
  from {
    opacity: 0;
    transform: translateX(-20px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

2.4 页面转场

/* 页面淡入淡出 */
.page-transition-enter {
  animation: page-fade-in 300ms var(--ease-enter) forwards;
}

.page-transition-exit {
  animation: page-fade-out 200ms var(--ease-exit) forwards;
}

@keyframes page-fade-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}

@keyframes page-fade-out {
  from { opacity: 1; transform: translateY(0); }
  to { opacity: 0; transform: translateY(-8px); }
}

/* 侧边栏展开/折叠 */
.sidebar {
  width: 280px;
  transition: width var(--duration-normal) var(--ease-standard),
              opacity var(--duration-fast) var(--ease-standard);
}

.sidebar.collapsed {
  width: 64px;
}

.sidebar.collapsed .sidebar-label {
  opacity: 0;
  transition: opacity var(--duration-fast) var(--ease-exit);
}

三、微交互设计

3.1 按钮状态

/* 按钮交互状态完整动效 */
.button {
  transition: all var(--duration-fast) var(--ease-standard);
  transform: translateZ(0); /* GPU 加速 */
}

.button:hover {
  background-color: var(--color-primary-dark);
  transform: translateY(-1px);
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.button:active {
  transform: translateY(0) scale(0.98);
  box-shadow: none;
  transition-duration: 50ms;
}

.button:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* 加载状态 */
.button.loading {
  pointer-events: none;
  opacity: 0.7;
}

.button.loading .button-text {
  opacity: 0;
}

.button.loading::after {
  content: '';
  position: absolute;
  width: 16px;
  height: 16px;
  border: 2px solid transparent;
  border-top-color: currentColor;
  border-radius: 50%;
  animation: spin 600ms linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* 成功反馈 */
.button.success {
  background-color: var(--color-success);
  animation: success-pulse 400ms var(--ease-bounce);
}

@keyframes success-pulse {
  0% { transform: scale(1); }
  50% { transform: scale(1.05); }
  100% { transform: scale(1); }
}

3.2 Toast 通知

/* Toast 进入/退出 */
.toast-enter {
  animation: toast-slide-in 300ms var(--ease-enter) forwards;
}

.toast-exit {
  animation: toast-slide-out 200ms var(--ease-exit) forwards;
}

@keyframes toast-slide-in {
  from {
    opacity: 0;
    transform: translateX(100%) scale(0.9);
  }
  to {
    opacity: 1;
    transform: translateX(0) scale(1);
  }
}

@keyframes toast-slide-out {
  from {
    opacity: 1;
    transform: translateX(0) scale(1);
    max-height: 100px;
  }
  to {
    opacity: 0;
    transform: translateX(100%) scale(0.9);
    max-height: 0;
    margin: 0;
    padding: 0;
  }
}

/* Toast 进度条(自动关闭倒计时) */
.toast-progress {
  height: 3px;
  background: var(--color-primary);
  animation: toast-countdown 5s linear forwards;
  transform-origin: left;
}

@keyframes toast-countdown {
  from { transform: scaleX(1); }
  to { transform: scaleX(0); }
}

3.3 模态框

/* 模态框出现 */
.modal-overlay {
  animation: overlay-fade-in 200ms var(--ease-standard) forwards;
}

.modal-content {
  animation: modal-enter 300ms var(--ease-enter) forwards;
}

@keyframes overlay-fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes modal-enter {
  from {
    opacity: 0;
    transform: scale(0.95) translateY(10px);
  }
  to {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

/* 模态框离开 */
.modal-overlay.closing {
  animation: overlay-fade-out 150ms var(--ease-exit) forwards;
}

.modal-content.closing {
  animation: modal-exit 200ms var(--ease-exit) forwards;
}

@keyframes overlay-fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

@keyframes modal-exit {
  from { opacity: 1; transform: scale(1); }
  to { opacity: 0; transform: scale(0.95); }
}

四、性能优化

4.1 GPU 加速

/* 只在 transform 和 opacity 上做动画(GPU 加速) */

/* 好:GPU 合成属性 */
.animated {
  transform: translateX(100px);
  opacity: 0.5;
  will-change: transform, opacity;
}

/* 差:触发重排/重绘 */
.animated-bad {
  left: 100px;        /* 触发 layout */
  width: 200px;       /* 触发 layout */
  background-color: red; /* 触发 paint */
}

/* will-change 使用规范 */
.about-to-animate {
  will-change: transform;  /* 提前告知浏览器 */
}

.done-animating {
  will-change: auto;       /* 动画结束后移除 */
}

4.2 减少动效偏好

/* 尊重系统设置 */
@media (prefers-reduced-motion: reduce) {
  /* 方案 1:移除所有动画 */
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }

  /* 方案 2:保留功能性过渡,只减少时长 */
  :root {
    --duration-fast: 0ms;
    --duration-normal: 50ms;
    --duration-slow: 100ms;
  }

  /* AI 思考状态:用静态文字替代动画 */
  .thinking-dots span {
    animation: none;
    opacity: 0.5;
  }
}

五、动效参考资源

资源 用途 地址
60fps.design 1840+ 应用动效参考 60fps.design
designspells.com 令人愉悦的 UI 细节 designspells.com
cubic-bezier.com 自定义缓动函数可视化 cubic-bezier.com
Framer Motion React 动效库 framer.com/motion
GSAP 高性能动画引擎 gsap.com
Lottie JSON 动画(After Effects 导出) lottiefiles.com

总结

AI 产品的动效设计核心是"有意图的运动"——每一个动画都应该回答"它帮助用户理解了什么?"。AI 思考状态用脉动/骨架屏缓解等待焦虑,流式打字用逐字显示传递实时感,消息进入用滑动动画建立空间关系,状态转换用缓动函数让变化平滑自然。性能红线:只在 transformopacity 上做动画(GPU 加速),动效时长控制在 100-400ms 之间,始终尊重 prefers-reduced-motion 系统偏好。


Maurice | maurice_wen@proton.me