AI 界面动效设计
原创
灵阙教研团队
B 基础 进阶 |
约 12 分钟阅读
更新于 2026-02-28 AI 导读
AI 界面动效设计 Loading 动画、过渡模式、微交互、骨架屏、流式文字效果与 CSS/Framer Motion 实践 一、动效的角色:减少认知负荷 动效不是装饰。在 AI 产品中,动效承担着三个关键职责: 解释因果:用户做了什么 -> 发生了什么(按钮点击 -> 面板展开) 填充等待:AI 思考需要时间,动效让用户感知到"系统在工作" 引导注意:新内容出现在哪里、下一步应该看哪里...
AI 界面动效设计
Loading 动画、过渡模式、微交互、骨架屏、流式文字效果与 CSS/Framer Motion 实践
一、动效的角色:减少认知负荷
动效不是装饰。在 AI 产品中,动效承担着三个关键职责:
- 解释因果:用户做了什么 -> 发生了什么(按钮点击 -> 面板展开)
- 填充等待:AI 思考需要时间,动效让用户感知到"系统在工作"
- 引导注意:新内容出现在哪里、下一步应该看哪里
动效设计的基本准则
| 原则 | 说明 | 反模式 |
|---|---|---|
| 有目的 | 每个动效都解决一个具体问题 | 为了"酷"而加动画 |
| 快速 | 过渡动画 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