AI产品的微交互设计

加载状态、反馈模式与过渡动画的工程实现


1. 微交互在 AI 产品中的角色

微交互(Micro-interactions)是界面中细微的、目的明确的交互时刻。在 AI 产品中,微交互承担着特殊使命:弥合用户操作与 AI 响应之间的时间鸿沟。当 AI 需要 5-30 秒才能返回结果时,精心设计的微交互是唯一能让用户保持信心的手段。

用户操作            AI 处理             结果呈现
  |                  |                   |
  v                  v                   v
[点击发送] ---> [思考动画] ---> [流式文字] ---> [完成反馈]
  |                  |                   |
  微交互1:          微交互2:            微交互3:
  按钮状态变化      加载/思考状态        内容出现动画
  发送确认          进度指示             操作反馈

1.1 微交互四要素

要素 定义 AI 产品示例
触发器 (Trigger) 启动微交互的事件 用户点击"发送"按钮
规则 (Rules) 微交互的逻辑 显示思考动画 + 流式输出
反馈 (Feedback) 用户感知到的变化 文字逐渐出现 + 光标闪烁
循环/模式 (Loops) 重复或状态变化规则 长时间等待时更新提示文案

2. 加载状态设计

2.1 AI 产品加载状态层级

等待时长 状态类型 表现形式
0-300ms 瞬时 无需加载指示
300ms-2s 短等待 按钮 spinner + 禁用状态
2s-10s 中等待 思考动画 + 文案变化
10s-30s 长等待 进度条 + 阶段提示 + 预计时间
> 30s 超长等待 后台处理 + 通知回调

2.2 思考动画实现

// ThinkingIndicator.tsx
interface ThinkingIndicatorProps {
  variant: 'dots' | 'wave' | 'pulse' | 'skeleton';
  messages?: string[];  // 交替显示的文案
  messageInterval?: number;  // 文案切换间隔 (ms)
  showEstimate?: boolean;
  estimateSeconds?: number;
}

function ThinkingIndicator({
  variant = 'dots',
  messages = ['正在思考...', '分析中...', '生成回复...'],
  messageInterval = 3000,
  showEstimate = false,
  estimateSeconds,
}: ThinkingIndicatorProps) {
  const [messageIndex, setMessageIndex] = useState(0);

  useEffect(() => {
    if (messages.length <= 1) return;
    const timer = setInterval(() => {
      setMessageIndex(i => (i + 1) % messages.length);
    }, messageInterval);
    return () => clearInterval(timer);
  }, [messages, messageInterval]);

  return (
    <div className="thinking" role="status" aria-live="polite">
      <ThinkingAnimation variant={variant} />
      <span className="thinking-text">{messages[messageIndex]}</span>
      {showEstimate && estimateSeconds && (
        <span className="thinking-estimate">
          预计 {estimateSeconds} 秒
        </span>
      )}
    </div>
  );
}
/* 三点弹跳动画 */
.thinking-dots {
  display: inline-flex;
  gap: 4px;
  align-items: center;
  height: 20px;
}

.thinking-dots__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--color-primary);
  animation: dot-bounce 1.4s ease-in-out infinite;
}

.thinking-dots__dot:nth-child(2) { animation-delay: 160ms; }
.thinking-dots__dot:nth-child(3) { animation-delay: 320ms; }

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

/* 波浪动画 */
.thinking-wave {
  display: inline-flex;
  gap: 3px;
  align-items: flex-end;
  height: 20px;
}

.thinking-wave__bar {
  width: 3px;
  border-radius: 2px;
  background: var(--color-primary);
  animation: wave 1.2s ease-in-out infinite;
}

.thinking-wave__bar:nth-child(1) { animation-delay: 0ms; }
.thinking-wave__bar:nth-child(2) { animation-delay: 100ms; }
.thinking-wave__bar:nth-child(3) { animation-delay: 200ms; }
.thinking-wave__bar:nth-child(4) { animation-delay: 300ms; }
.thinking-wave__bar:nth-child(5) { animation-delay: 400ms; }

@keyframes wave {
  0%, 100% { height: 4px; }
  50% { height: 16px; }
}

2.3 骨架屏

AI 回复骨架屏:
+----------------------------------------+
| [头像]  AI 助手                         |
+----------------------------------------+
| ====================================== |
| ==========================             |
| ================================       |
|                                        |
| +----------------------------------+   |
| |  [代码块骨架]                     |   |
| |  ============================    |   |
| |  ======================          |   |
| |  ============================    |   |
| +----------------------------------+   |
|                                        |
| ==================                     |
| ====================================== |
+----------------------------------------+
.skeleton {
  background: linear-gradient(
    90deg,
    var(--color-bg-tertiary) 25%,
    var(--color-bg-secondary) 50%,
    var(--color-bg-tertiary) 75%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s ease-in-out infinite;
  border-radius: var(--radius-sm);
}

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

.skeleton-text {
  height: 16px;
  margin-bottom: 8px;
}

.skeleton-text:last-child {
  width: 60%;
}

3. 流式输出动画

3.1 逐字显示

// StreamingText.tsx
function StreamingText({ text, isStreaming, speed = 30 }) {
  const [displayedText, setDisplayedText] = useState('');
  const indexRef = useRef(0);

  useEffect(() => {
    if (!isStreaming) {
      setDisplayedText(text);
      return;
    }

    const timer = setInterval(() => {
      if (indexRef.current < text.length) {
        setDisplayedText(text.slice(0, indexRef.current + 1));
        indexRef.current++;
      } else {
        clearInterval(timer);
      }
    }, speed);

    return () => clearInterval(timer);
  }, [text, isStreaming, speed]);

  return (
    <div className="streaming-text">
      <span>{displayedText}</span>
      {isStreaming && <span className="cursor" aria-hidden="true" />}
    </div>
  );
}

3.2 逐块显示(Markdown 渲染)

// 按段落块显示,而非逐字,更适合长内容
function StreamingBlocks({ blocks, isStreaming }) {
  const [visibleCount, setVisibleCount] = useState(0);

  useEffect(() => {
    if (!isStreaming) {
      setVisibleCount(blocks.length);
      return;
    }

    if (visibleCount < blocks.length) {
      const timer = setTimeout(() => {
        setVisibleCount(c => c + 1);
      }, 100);  // 每 100ms 显示一个块
      return () => clearTimeout(timer);
    }
  }, [blocks.length, visibleCount, isStreaming]);

  return (
    <div className="streaming-blocks">
      {blocks.slice(0, visibleCount).map((block, i) => (
        <div
          key={i}
          className="block-appear"
          style={{ animationDelay: `${i * 50}ms` }}
        >
          {renderBlock(block)}
        </div>
      ))}
    </div>
  );
}
.block-appear {
  animation: block-fade-in 300ms ease-out forwards;
  opacity: 0;
}

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

4. 按钮反馈

4.1 发送按钮状态机

idle (空闲)
  |
  +---> [用户点击]
  |
  v
sending (发送中)
  |
  +---> [API 确认] --> sent (已发送) --> idle
  |
  +---> [超时/错误] --> error (错误) --> idle
function SendButton({ onSend, disabled }) {
  const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');

  async function handleClick() {
    setState('sending');
    try {
      await onSend();
      setState('sent');
      setTimeout(() => setState('idle'), 1500);
    } catch {
      setState('error');
      setTimeout(() => setState('idle'), 2000);
    }
  }

  return (
    <button
      onClick={handleClick}
      disabled={disabled || state === 'sending'}
      className={`send-btn send-btn--${state}`}
      aria-label={
        state === 'sending' ? '发送中' :
        state === 'sent' ? '已发送' :
        state === 'error' ? '发送失败' : '发送'
      }
    >
      {state === 'idle' && <ArrowUpIcon />}
      {state === 'sending' && <SpinnerIcon />}
      {state === 'sent' && <CheckIcon />}
      {state === 'error' && <AlertIcon />}
    </button>
  );
}

4.2 复制按钮反馈

[复制代码]  --点击-->  [已复制] (1.5s 后恢复)

实现细节:
- 图标从 "复制" 变为 "对勾"
- 背景色短暂变为成功色
- 过渡动画 150ms
.copy-btn {
  transition: all var(--duration-fast) var(--easing-default);
}

.copy-btn--copied {
  color: var(--color-success);
  background: var(--color-success-subtle);
}

.copy-btn__icon {
  transition: transform var(--duration-fast) var(--easing-out);
}

.copy-btn--copied .copy-btn__icon {
  transform: scale(1.1);
}

5. Toast 通知

5.1 设计规范

位置: 右下角(桌面)/ 底部居中(移动端)

成功 Toast:
+-------------------------------------------+
| [v] 回复已复制到剪贴板              [关闭] |
+-------------------------------------------+
  颜色: 成功绿边框 + 浅绿背景

错误 Toast:
+-------------------------------------------+
| [!] 生成失败,请重试                [重试] |
+-------------------------------------------+
  颜色: 错误红边框 + 浅红背景

规则:
- 自动消失: 3-5 秒
- 可手动关闭
- 最多同时显示 3 条
- 新 Toast 从底部进入,旧 Toast 向上移动
/* Toast 进出动画 */
.toast-enter {
  animation: toast-slide-in 300ms var(--easing-out) forwards;
}

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

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

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

6. 赞/踩反馈

6.1 交互流程

默认状态: [赞 (灰)] [踩 (灰)]
   |
   +---> 用户点赞
   |       v
   |     [赞 (蓝, 填充)] [踩 (灰)]
   |       + 按钮微缩放动画 (scale 0.9 -> 1.1 -> 1.0)
   |       + (可选) 展开反馈文本框: "什么对你有帮助?"
   |
   +---> 用户点踩
           v
         [赞 (灰)] [踩 (红, 填充)]
           + 展开必填反馈: "请告诉我们哪里不好"
           + 预设标签: [不准确] [不完整] [有害] [其他]
/* 赞踩按钮的微交互 */
.feedback-btn {
  transition: all var(--duration-fast) var(--easing-out);
  transform-origin: center;
}

.feedback-btn:active {
  transform: scale(0.9);
}

.feedback-btn--active {
  animation: feedback-pop 400ms var(--easing-out);
}

@keyframes feedback-pop {
  0% { transform: scale(0.9); }
  50% { transform: scale(1.15); }
  100% { transform: scale(1); }
}

7. 过渡动画

7.1 页面/面板过渡

/* 侧边面板滑入 */
.panel-enter {
  animation: slide-in-right 300ms var(--easing-out) forwards;
}

.panel-exit {
  animation: slide-out-right 200ms var(--easing-in) forwards;
}

@keyframes slide-in-right {
  from { transform: translateX(100%); }
  to { transform: translateX(0); }
}

/* Modal 弹窗 */
.modal-overlay-enter {
  animation: fade-in 200ms ease-out;
}

.modal-content-enter {
  animation: modal-pop 300ms var(--easing-out);
}

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

7.2 列表动画

/* 消息列表新增项动画 */
.message-enter {
  animation: message-appear 300ms var(--easing-out) forwards;
}

@keyframes message-appear {
  from {
    opacity: 0;
    transform: translateY(16px);
    max-height: 0;
  }
  to {
    opacity: 1;
    transform: translateY(0);
    max-height: 500px;
  }
}

8. 手势交互

8.1 移动端手势

手势 动作 反馈
左滑消息 显示操作菜单(复制/删除) 弹出操作按钮
右滑消息 回复该消息 引用预览
长按消息 选择/复制 高亮 + 震动反馈
下拉 加载更多历史消息 拉伸动画 + 刷新指示
双指缩放 调整字号 实时字号变化

8.2 触觉反馈

// 使用 Haptic Feedback API
function triggerHaptic(type: 'light' | 'medium' | 'heavy') {
  if ('vibrate' in navigator) {
    const patterns = {
      light: [10],
      medium: [20],
      heavy: [30, 10, 30],
    };
    navigator.vibrate(patterns[type]);
  }
}

9. 性能守则

9.1 动画性能规则

规则 说明
只动 transform 和 opacity 这两个属性不触发重排/重绘
使用 will-change 提示浏览器优化,但不要滥用
避免动画 layout 属性 width/height/margin/padding 会触发重排
60fps 目标 每帧预算 16.67ms
减少运动偏好 尊重 prefers-reduced-motion
/* 正确: 只用 transform + opacity */
.animated {
  will-change: transform, opacity;
  transition: transform 300ms ease, opacity 300ms ease;
}

/* 错误: 动画 layout 属性 */
.animated-wrong {
  transition: width 300ms, height 300ms;  /* 触发重排 */
}

/* 减少运动偏好 */
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

9.2 流式渲染优化

// 使用 requestAnimationFrame 批量更新 DOM
function StreamingRenderer({ chunks }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const pendingChunks = useRef<string[]>([]);
  const rafId = useRef<number>();

  useEffect(() => {
    function flush() {
      if (pendingChunks.current.length > 0 && containerRef.current) {
        const batch = pendingChunks.current.join('');
        pendingChunks.current = [];
        containerRef.current.textContent += batch;
      }
    }

    function scheduleFlush() {
      if (rafId.current) cancelAnimationFrame(rafId.current);
      rafId.current = requestAnimationFrame(flush);
    }

    // 收到新 chunk 时入队,不立即更新 DOM
    for (const chunk of chunks) {
      pendingChunks.current.push(chunk);
      scheduleFlush();
    }

    return () => {
      if (rafId.current) cancelAnimationFrame(rafId.current);
    };
  }, [chunks]);

  return <div ref={containerRef} />;
}

10. 微交互清单

AI 产品中必须覆盖的微交互清单:

场景 微交互 优先级
发送消息 按钮状态 + 消息出现 P0
AI 思考 思考动画 + 文案变化 P0
流式输出 逐字/逐块显示 + 光标 P0
复制代码 图标变化 + Toast 确认 P0
赞/踩 按钮动画 + 反馈展开 P1
重试请求 旋转动画 + 结果替换 P1
切换模型 下拉过渡 + 标签动画 P1
滚动到底 平滑滚动 + 新消息指示 P1
长等待 进度条 + 阶段文案 P2
错误恢复 错误 Toast + 重试按钮 P2
会话切换 列表滑动 + 内容淡入淡出 P2
Token 计数 实时更新 + 接近限额警告 P2

Maurice | maurice_wen@proton.me