AI产品的微交互设计
原创
灵阙教研团队
B 基础 进阶 |
约 10 分钟阅读
更新于 2026-02-28 AI 导读
AI产品的微交互设计 加载状态、反馈模式与过渡动画的工程实现 1. 微交互在 AI 产品中的角色 微交互(Micro-interactions)是界面中细微的、目的明确的交互时刻。在 AI 产品中,微交互承担着特殊使命:弥合用户操作与 AI 响应之间的时间鸿沟。当 AI 需要 5-30 秒才能返回结果时,精心设计的微交互是唯一能让用户保持信心的手段。 用户操作 AI 处理 结果呈现 | | |...
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