AI 界面动效设计

Loading 动画、过渡模式、微交互、骨架屏、流式文字效果与 CSS/Framer Motion 实践


一、动效的角色:减少认知负荷

动效不是装饰。在 AI 产品中,动效承担着三个关键职责:

  1. 解释因果:用户做了什么 -> 发生了什么(按钮点击 -> 面板展开)
  2. 填充等待:AI 思考需要时间,动效让用户感知到"系统在工作"
  3. 引导注意:新内容出现在哪里、下一步应该看哪里

动效设计的基本准则

原则 说明 反模式
有目的 每个动效都解决一个具体问题 为了"酷"而加动画
快速 过渡动画 150-300ms 超过 500ms 的过渡
可中断 用户操作能立即中断动画 强制用户等动画播完
一致 同类交互使用同类动效 每个按钮动效都不同
可关闭 尊重 prefers-reduced-motion 忽略无障碍偏好
不阻塞 动效不阻止用户操作 动画播放时禁用输入

时长参考

状态变化(颜色/透明度): 100-150ms
小型元素过渡(按钮/图标): 150-200ms
中型元素过渡(卡片/面板): 200-300ms
大型元素过渡(页面/模态框): 300-400ms
复杂编排动画: 400-600ms

二、AI 思考状态动画

2.1 脉冲点阵(最经典的 AI 加载指示器)

/* thinking-dots.css */

.ai-thinking {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 8px 16px;
}

.ai-thinking-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background-color: var(--color-primary, #3b82f6);
  animation: thinking-pulse 1.4s ease-in-out infinite;
}

.ai-thinking-dot:nth-child(2) {
  animation-delay: 0.2s;
}

.ai-thinking-dot:nth-child(3) {
  animation-delay: 0.4s;
}

@keyframes thinking-pulse {
  0%, 80%, 100% {
    opacity: 0.3;
    transform: scale(0.8);
  }
  40% {
    opacity: 1;
    transform: scale(1);
  }
}

/* Reduced motion: static indicator */
@media (prefers-reduced-motion: reduce) {
  .ai-thinking-dot {
    animation: none;
    opacity: 0.6;
    transform: scale(1);
  }
}

2.2 React 组件实现

// components/AIThinking.tsx
import { motion, AnimatePresence } from 'framer-motion';

interface AIThinkingProps {
  isVisible: boolean;
  label?: string;
}

export function AIThinking({ isVisible, label = 'AI is thinking' }: AIThinkingProps) {
  return (
    <AnimatePresence>
      {isVisible && (
        <motion.div
          initial={{ opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -10 }}
          transition={{ duration: 0.2 }}
          className="flex items-center gap-2 px-4 py-2 text-sm text-gray-500"
          role="status"
          aria-label={label}
        >
          <div className="flex gap-1">
            {[0, 1, 2].map((i) => (
              <motion.div
                key={i}
                className="w-2 h-2 rounded-full bg-blue-500"
                animate={{
                  opacity: [0.3, 1, 0.3],
                  scale: [0.8, 1, 0.8],
                }}
                transition={{
                  duration: 1.4,
                  repeat: Infinity,
                  delay: i * 0.2,
                  ease: 'easeInOut',
                }}
              />
            ))}
          </div>
          <span>{label}</span>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

2.3 进度型加载(长任务)

对于 AI 视频生成等长任务,脉冲点不够——用户需要知道"到哪一步了":

// components/StageProgress.tsx
import { motion } from 'framer-motion';

interface Stage {
  id: string;
  label: string;
  status: 'pending' | 'running' | 'done' | 'error';
}

export function StageProgress({ stages }: { stages: Stage[] }) {
  return (
    <div className="flex items-center gap-2">
      {stages.map((stage, index) => (
        <div key={stage.id} className="flex items-center">
          {/* Stage indicator */}
          <motion.div
            className={`
              w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium
              ${stage.status === 'done' ? 'bg-green-500 text-white' : ''}
              ${stage.status === 'running' ? 'bg-blue-500 text-white' : ''}
              ${stage.status === 'pending' ? 'bg-gray-200 text-gray-500' : ''}
              ${stage.status === 'error' ? 'bg-red-500 text-white' : ''}
            `}
            animate={
              stage.status === 'running'
                ? { scale: [1, 1.1, 1], boxShadow: ['0 0 0 0 rgba(59,130,246,0.4)', '0 0 0 8px rgba(59,130,246,0)', '0 0 0 0 rgba(59,130,246,0)'] }
                : {}
            }
            transition={
              stage.status === 'running'
                ? { duration: 2, repeat: Infinity }
                : {}
            }
          >
            {stage.status === 'done' ? 'OK' : index + 1}
          </motion.div>

          {/* Connector line */}
          {index < stages.length - 1 && (
            <div className="w-8 h-0.5 mx-1 bg-gray-200 overflow-hidden">
              <motion.div
                className="h-full bg-blue-500"
                initial={{ width: '0%' }}
                animate={{
                  width: stage.status === 'done' ? '100%' : '0%',
                }}
                transition={{ duration: 0.3 }}
              />
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

三、过渡模式

3.1 页面过渡

// components/PageTransition.tsx
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';

const pageVariants = {
  initial: { opacity: 0, y: 20 },
  enter: { opacity: 1, y: 0 },
  exit: { opacity: 0, y: -20 },
};

const pageTransition = {
  type: 'tween',
  ease: 'easeInOut',
  duration: 0.25,
};

export function PageTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={pathname}
        initial="initial"
        animate="enter"
        exit="exit"
        variants={pageVariants}
        transition={pageTransition}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  );
}

3.2 列表动画(Stagger)

// components/AnimatedList.tsx
import { motion } from 'framer-motion';

const containerVariants = {
  hidden: {},
  visible: {
    transition: {
      staggerChildren: 0.05, // 50ms between each item
    },
  },
};

const itemVariants = {
  hidden: { opacity: 0, x: -20 },
  visible: {
    opacity: 1,
    x: 0,
    transition: { duration: 0.2 },
  },
};

export function AnimatedList({ items }: { items: string[] }) {
  return (
    <motion.ul
      variants={containerVariants}
      initial="hidden"
      animate="visible"
    >
      {items.map((item, index) => (
        <motion.li
          key={index}
          variants={itemVariants}
          className="py-2 px-4"
        >
          {item}
        </motion.li>
      ))}
    </motion.ul>
  );
}

3.3 展开/折叠

// components/Collapsible.tsx
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';

export function Collapsible({
  title,
  children,
}: {
  title: string;
  children: React.ReactNode;
}) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="border rounded-lg overflow-hidden">
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="w-full px-4 py-3 flex justify-between items-center"
      >
        <span className="font-medium">{title}</span>
        <motion.span
          animate={{ rotate: isOpen ? 180 : 0 }}
          transition={{ duration: 0.2 }}
        >
          v
        </motion.span>
      </button>

      <AnimatePresence initial={false}>
        {isOpen && (
          <motion.div
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: 'auto', opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.25, ease: 'easeInOut' }}
          >
            <div className="px-4 pb-3">
              {children}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

四、骨架屏(Skeleton Screens)

骨架屏比 spinner 更好,因为它预示了即将出现的内容结构,减少感知等待时间。

4.1 基础骨架组件

// components/Skeleton.tsx

function Skeleton({
  className = '',
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={`animate-pulse rounded-md bg-gray-200 dark:bg-gray-700 ${className}`}
      {...props}
    />
  );
}

// Specific skeleton patterns
function CardSkeleton() {
  return (
    <div className="p-4 space-y-3">
      <Skeleton className="h-4 w-3/4" />
      <Skeleton className="h-3 w-full" />
      <Skeleton className="h-3 w-5/6" />
      <div className="flex gap-2 mt-4">
        <Skeleton className="h-8 w-16 rounded-full" />
        <Skeleton className="h-8 w-16 rounded-full" />
      </div>
    </div>
  );
}

function ChatMessageSkeleton() {
  return (
    <div className="flex gap-3 p-4">
      {/* Avatar */}
      <Skeleton className="h-8 w-8 rounded-full shrink-0" />
      {/* Message content */}
      <div className="flex-1 space-y-2">
        <Skeleton className="h-3 w-24" />
        <Skeleton className="h-3 w-full" />
        <Skeleton className="h-3 w-4/5" />
      </div>
    </div>
  );
}

function TableSkeleton({ rows = 5 }: { rows?: number }) {
  return (
    <div className="space-y-2">
      {/* Header */}
      <div className="flex gap-4 pb-2 border-b">
        <Skeleton className="h-3 w-1/4" />
        <Skeleton className="h-3 w-1/4" />
        <Skeleton className="h-3 w-1/4" />
        <Skeleton className="h-3 w-1/4" />
      </div>
      {/* Rows */}
      {Array.from({ length: rows }).map((_, i) => (
        <div key={i} className="flex gap-4 py-2">
          <Skeleton className="h-3 w-1/4" />
          <Skeleton className="h-3 w-1/4" />
          <Skeleton className="h-3 w-1/4" />
          <Skeleton className="h-3 w-1/4" />
        </div>
      ))}
    </div>
  );
}

export { Skeleton, CardSkeleton, ChatMessageSkeleton, TableSkeleton };

4.2 CSS 骨架动画

/* skeleton-animation.css */

/* 方案 1: Pulse (Tailwind 内置) */
.skeleton-pulse {
  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

/* 方案 2: Shimmer (更精致) */
.skeleton-shimmer {
  background: linear-gradient(
    90deg,
    var(--skeleton-base, #e2e8f0) 0%,
    var(--skeleton-shine, #f1f5f9) 50%,
    var(--skeleton-base, #e2e8f0) 100%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
}

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

/* Dark mode */
@media (prefers-color-scheme: dark) {
  .skeleton-shimmer {
    --skeleton-base: #334155;
    --skeleton-shine: #475569;
  }
}

五、流式文字效果(Streaming Text)

AI 聊天产品的标志性交互——文字像打字机一样逐字出现。

5.1 CSS 光标 + 流式渲染

// components/StreamingText.tsx
import { useState, useEffect, useRef } from 'react';

interface StreamingTextProps {
  content: string;
  isStreaming: boolean;
  speed?: number; // ms per character (for simulated streaming)
}

export function StreamingText({
  content,
  isStreaming,
}: StreamingTextProps) {
  return (
    <div className="relative">
      {/* Rendered content with markdown */}
      <div className="prose prose-sm dark:prose-invert">
        {content}
      </div>

      {/* Streaming cursor */}
      {isStreaming && (
        <span
          className="inline-block w-0.5 h-5 bg-blue-500 ml-0.5 align-text-bottom streaming-cursor"
          aria-hidden="true"
        />
      )}
    </div>
  );
}
/* streaming-cursor.css */

.streaming-cursor {
  animation: cursor-blink 0.8s step-end infinite;
}

@keyframes cursor-blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

/* Smooth streaming text appearance */
.streaming-text-enter {
  animation: text-fade-in 0.15s ease-out;
}

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

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
  .streaming-cursor {
    animation: none;
    opacity: 1;
  }
  .streaming-text-enter {
    animation: none;
  }
}

5.2 SSE 流式渲染集成

// hooks/useStreamingResponse.ts
import { useState, useCallback, useRef } from 'react';

interface UseStreamingOptions {
  onComplete?: (fullText: string) => void;
  onError?: (error: Error) => void;
}

export function useStreamingResponse(options: UseStreamingOptions = {}) {
  const [text, setText] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  const startStream = useCallback(async (url: string, body: unknown) => {
    // Cancel previous stream
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;

    setIsStreaming(true);
    setText('');

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
        signal: controller.signal,
      });

      const reader = response.body!.getReader();
      const decoder = new TextDecoder();
      let fullText = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        fullText += chunk;
        setText(fullText);
      }

      setIsStreaming(false);
      options.onComplete?.(fullText);
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        setIsStreaming(false);
        options.onError?.(err as Error);
      }
    }
  }, [options]);

  const stopStream = useCallback(() => {
    abortRef.current?.abort();
    setIsStreaming(false);
  }, []);

  return { text, isStreaming, startStream, stopStream };
}

六、微交互(Micro-interactions)

6.1 按钮反馈

// components/InteractiveButton.tsx
import { motion } from 'framer-motion';

export function InteractiveButton({
  children,
  onClick,
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return (
    <motion.button
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.98 }}
      transition={{ type: 'spring', stiffness: 500, damping: 30 }}
      onClick={onClick}
      {...props}
    >
      {children}
    </motion.button>
  );
}

6.2 Toast 通知

// components/Toast.tsx
import { motion, AnimatePresence } from 'framer-motion';

interface ToastProps {
  message: string;
  type: 'success' | 'error' | 'info' | 'warning';
  isVisible: boolean;
  onDismiss: () => void;
}

const toastColors = {
  success: 'bg-green-50 border-green-200 text-green-800',
  error: 'bg-red-50 border-red-200 text-red-800',
  info: 'bg-blue-50 border-blue-200 text-blue-800',
  warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
};

export function Toast({ message, type, isVisible, onDismiss }: ToastProps) {
  return (
    <AnimatePresence>
      {isVisible && (
        <motion.div
          initial={{ opacity: 0, y: -20, scale: 0.95 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: -20, scale: 0.95 }}
          transition={{ duration: 0.2, ease: 'easeOut' }}
          className={`
            fixed top-4 right-4 z-50 px-4 py-3 rounded-lg border
            shadow-lg max-w-sm ${toastColors[type]}
          `}
          role="alert"
        >
          <div className="flex items-center justify-between gap-3">
            <span className="text-sm">{message}</span>
            <button
              onClick={onDismiss}
              className="text-current opacity-50 hover:opacity-100 text-lg leading-none"
              aria-label="Dismiss"
            >
              x
            </button>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

6.3 复制成功反馈

// components/CopyButton.tsx
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';

export function CopyButton({ text }: { text: string }) {
  const [copied, setCopied] = useState(false);

  const handleCopy = async () => {
    await navigator.clipboard.writeText(text);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <button
      onClick={handleCopy}
      className="relative p-2 rounded hover:bg-gray-100"
      aria-label="Copy to clipboard"
    >
      <AnimatePresence mode="wait">
        {copied ? (
          <motion.span
            key="check"
            initial={{ scale: 0 }}
            animate={{ scale: 1 }}
            exit={{ scale: 0 }}
            transition={{ type: 'spring', stiffness: 500 }}
            className="text-green-500 text-sm"
          >
            Copied
          </motion.span>
        ) : (
          <motion.span
            key="copy"
            initial={{ scale: 0 }}
            animate={{ scale: 1 }}
            exit={{ scale: 0 }}
            className="text-gray-400 text-sm"
          >
            Copy
          </motion.span>
        )}
      </AnimatePresence>
    </button>
  );
}

七、性能优化

7.1 动画性能原则

属性 GPU 加速 推荐 避免
transform translate, scale, rotate -
opacity 淡入淡出 -
width/height - 用 transform: scale 替代
top/left - 用 transform: translate 替代
background-color - 用 opacity 叠加替代
box-shadow - 用伪元素 + opacity 替代

7.2 关键优化手段

/* performance.css */

/* 提前告知浏览器哪些属性会变化 */
.will-animate {
  will-change: transform, opacity;
}

/* 动画结束后移除 will-change(避免内存浪费) */
.animation-done {
  will-change: auto;
}

/* 强制 GPU 合成层 */
.gpu-layer {
  transform: translateZ(0);
}

/* 避免布局抖动:固定尺寸 */
.skeleton-container {
  min-height: 200px;  /* 防止内容加载后的布局跳动 */
}

7.3 Framer Motion 优化

// Use layout animations sparingly
// BAD: animating layout on every re-render
<motion.div layout>...</motion.div>

// GOOD: only animate layout when necessary
<motion.div layout="position">...</motion.div>

// Use layoutId for shared element transitions
<motion.div layoutId={`card-${id}`}>...</motion.div>

// Reduce motion preference
import { useReducedMotion } from 'framer-motion';

function MyComponent() {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      animate={{ opacity: 1 }}
      transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.3 }}
    />
  );
}

八、动效设计检查清单

Animation Design Checklist:
- [ ] 每个动效有明确目的(解释/等待/引导)
- [ ] 过渡时长: 状态变化 <= 150ms, 元素过渡 <= 300ms
- [ ] 支持 prefers-reduced-motion (禁用或简化动画)
- [ ] 只动画 transform 和 opacity(GPU 加速)
- [ ] 骨架屏匹配实际内容结构
- [ ] 流式文字有闪烁光标
- [ ] 长任务有进度指示(不只是 spinner)
- [ ] 动画不阻塞用户操作
- [ ] 暗黑模式下动效颜色适配
- [ ] 移动端触摸反馈 (active state)

好的动效让用户感觉界面"有生命"但不"抢戏"。当用户注意到动画本身而非它传达的信息时,说明动效过度了。


Maurice | maurice_wen@proton.me