Framer Motion 交易界面动画设计指南
快速导览:本文深入探讨 Framer Motion 在交易系统的应用,从基础动画到复杂互动,提供打造流畅专业交易界面的完整指南。预计阅读时间 12 分钟。
为什么交易界面需要动画?
在高性能的交易环境中,动画不只是装饰 —— 它是信息传达的关键工具。当价格在毫秒间变动,适当的动画能帮助交易者:
- 感知变化:价格闪烁提示变动方向
- 理解状态:加载动画降低焦虑
- 引导注意力:重要事件视觉突出
- 建立回馈:操作确认强化信心
根据 Nielsen Norman Group 研究,适当的动画能提升使用者任务完成率达 15%,错误率降低 20%。
交易界面动画的三大原则
| 原则 | 说明 | 例子 |
|:---|:---|:---|
| 功能性 | 动画传达信息 | 价格上涨绿色闪烁 |
| 回馈性 | 确认操作结果 | 按钮点击缩放效果 |
| 流畅性 | 减少认知负荷 | 页面转场平滑过渡 |
Framer Motion 核心概念
为什么选择 Framer Motion?
Framer Motion 是 React 生态最强大的动画库:
| 特性 | Framer Motion | CSS Animation | React Spring |
|:---|:---|:---|:---|
| 声明式 API | ✅ 直观 | ⚠️ 需 CSS | ✅ 直观 |
| 手势支持 | ✅ 内置 | ❌ 需额外 | ⚠️ 有限 |
| 布局动画 | ✅ 自动 | ❌ 手动计算 | ⚠️ 复杂 |
| 滚动触发 | ✅ useScroll | ❌ 需 JS | ⚠️ 需额外 |
| 性能 | ✅ GPU 加速 | ✅ GPU 加速 | ✅ GPU 加速 |
| 学习曲线 | 中等 | 低 | 高 |
核心 API 速览
// motion 组件 - 基础动画
import { motion } from 'framer-motion';
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
/>
// useAnimation - 程序控制
const controls = useAnimation();
controls.start({ scale: 1.2 });
// AnimatePresence - 进出场动画
<AnimatePresence>
{isVisible && <motion.div exit={{ opacity: 0 }} />}
</AnimatePresence>
// useMotionValue - 响应式动画
const x = useMotionValue(0);
const opacity = useTransform(x, [-100, 0, 100], [0, 1, 0]);
交易界面实战动画
1. 价格变动闪烁动画
// components/animations/price-flash.tsx
import { motion, useAnimation } from 'framer-motion';
import { useEffect } from 'react';
interface PriceFlashProps {
price: number;
prevPrice: number;
children: React.ReactNode;
}
export function PriceFlash({ price, prevPrice, children }: PriceFlashProps) {
const controls = useAnimation();
useEffect(() => {
if (price > prevPrice) {
controls.start({
backgroundColor: ['transparent', 'rgba(34, 197, 94, 0.3)', 'transparent'],
transition: { duration: 0.5 }
});
} else if (price < prevPrice) {
controls.start({
backgroundColor: ['transparent', 'rgba(239, 68, 68, 0.3)', 'transparent'],
transition: { duration: 0.5 }
});
}
}, [price, prevPrice, controls]);
return (
<motion.span animate={controls} className="inline-block rounded px-1">
{children}
</motion.span>
);
}
// 使用
function PriceDisplay({ symbol }: { symbol: string }) {
const { price, prevPrice } = usePrice(symbol);
return (
<PriceFlash price={price} prevPrice={prevPrice}>
<span className="font-mono text-2xl">{price.toFixed(2)}</span>
</PriceFlash>
);
}
2. 数字滚动动画
// components/animations/animated-number.tsx
import { useSpring, useTransform, motion } from 'framer-motion';
interface AnimatedNumberProps {
value: number;
duration?: number;
format?: (n: number) => string;
}
export function AnimatedNumber({
value,
duration = 0.5,
format = (n) => n.toFixed(2)
}: AnimatedNumberProps) {
const spring = useSpring(value, {
stiffness: 100,
damping: 30,
duration: duration * 1000
});
const display = useTransform(spring, (latest) => format(latest));
return <motion.span>{display}</motion.span>;
}
// 使用
function PnLDisplay({ value }: { value: number }) {
return (
<div className={cn('text-2xl font-bold', value >= 0 ? 'text-green-500' : 'text-red-500')}>
<AnimatedNumber
value={value}
format={(n) => `${n >= 0 ? '+' : ''}${n.toFixed(2)} USDT`}
/>
</div>
);
}
3. 列表进出场动画
// components/animations/animated-list.tsx
import { motion, AnimatePresence } from 'framer-motion';
interface AnimatedListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
}
const listVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.05 }
}
};
const itemVariants = {
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 }
};
export function AnimatedList<T>({ items, renderItem, keyExtractor }: AnimatedListProps<T>) {
return (
<motion.div
variants={listVariants}
initial="hidden"
animate="visible"
>
<AnimatePresence mode="popLayout">
{items.map((item, index) => (
<motion.div
key={keyExtractor(item)}
variants={itemVariants}
layout
exit="exit"
>
{renderItem(item, index)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
);
}
// 使用:交易记录列表
function TradeHistory({ trades }: { trades: Trade[] }) {
return (
<AnimatedList
items={trades}
keyExtractor={(trade) => trade.id}
renderItem={(trade) => (
<TradeRow trade={trade} />
)}
/>
);
}
4. 页面转场动画
// components/animations/page-transition.tsx
import { motion } from 'framer-motion';
import { useLocation } from 'react-router-dom';
const pageVariants = {
initial: { opacity: 0, y: 20 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.3, ease: 'easeOut' }
},
exit: {
opacity: 0,
y: -20,
transition: { duration: 0.2 }
}
};
export function PageTransition({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<motion.div
key={location.pathname}
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
>
{children}
</motion.div>
);
}
// 路由配置
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route path="/dashboard" element={
<PageTransition><Dashboard /></PageTransition>
} />
<Route path="/bots" element={
<PageTransition><BotList /></PageTransition>
} />
</Routes>
</AnimatePresence>
5. 手势互动:滑动删除
// components/animations/swipe-to-delete.tsx
import { motion, useMotionValue, useTransform } from 'framer-motion';
import { useState } from 'react';
interface SwipeToDeleteProps {
onDelete: () => void;
children: React.ReactNode;
}
export function SwipeToDelete({ onDelete, children }: SwipeToDeleteProps) {
const [isDragging, setIsDragging] = useState(false);
const x = useMotionValue(0);
const opacity = useTransform(x, [-100, -50, 0], [1, 0.5, 0]);
const background = useTransform(
x,
[-200, -100, 0],
['rgb(239, 68, 68)', 'rgba(239, 68, 68, 0.5)', 'transparent']
);
return (
<motion.div className="relative overflow-hidden rounded-lg">
{/* 背景删除提示 */}
<motion.div
className="absolute inset-0 flex items-center justify-end pr-4 text-white"
style={{ opacity, background }}
>
删除
</motion.div>
{/* 可滑动内容 */}
<motion.div
drag="x"
dragConstraints={{ left: -100, right: 0 }}
dragElastic={0.2}
style={{ x }}
onDragStart={() => setIsDragging(true)}
onDragEnd={(_, info) => {
setIsDragging(false);
if (info.offset.x < -80) {
onDelete();
}
}}
className={cn('relative bg-card', isDragging && 'cursor-grabbing')}
>
{children}
</motion.div>
</motion.div>
);
}
进阶动画技巧
布局动画(Layout Animations)
// 自动处理布局变化
<motion.div
layout
layoutId="unique-id"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
// 实战:展开/收起卡片
function ExpandableCard({ title, children }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<motion.div
layout
onClick={() => setIsExpanded(!isExpanded)}
className="cursor-pointer rounded-lg bg-card p-4"
>
<motion.h3 layout>{title}</motion.h3>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
滚动触发动画
// hooks/use-scroll-animation.ts
import { useScroll, useTransform } from 'framer-motion';
export function useScrollAnimation(ref: RefObject<HTMLElement>) {
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start']
});
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
const y = useTransform(scrollYProgress, [0, 0.2], [100, 0]);
return { opacity, y, scrollYProgress };
}
// 使用
function FeatureSection() {
const ref = useRef(null);
const { opacity, y } = useScrollAnimation(ref);
return (
<motion.section ref={ref} style={{ opacity, y }}>
{/* 内容 */}
</motion.section>
);
}
性能优化策略
will-change 与 GPU 加速
// 自动应用 will-change
<motion.div
initial={false}
animate={{ x: 100 }}
transition={{
type: 'tween',
// 使用 transform 和 opacity 确保 GPU 加速
}}
/>
// 避免布局属性动画(会触发重排)
// ❌ 避免
animate={{ width: 100, height: 100, left: 100 }}
// ✅ 推荐
animate={{ x: 100, y: 100, scale: 1.5 }}
减少动画数量
// 使用 useReducedMotion 尊重使用者偏好
import { useReducedMotion } from 'framer-motion';
function AccessibleAnimation({ children }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
animate={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
>
{children}
</motion.div>
);
}
实战案例:Sentinel Bot 动画系统
动画使用统计
| 动画类型 | 使用场景 | 性能影响 |
|:---|:---|:---:|
| 价格闪烁 | 实时行情 | 低 |
| 数字滚动 | PnL 显示 | 中 |
| 列表动画 | 交易记录 | 中 |
| 页面转场 | 路由切换 | 低 |
| 手势互动 | 快速操作 | 低 |
| 加载骨架 | 数据获取 | 低 |
设计原则
Sentinel Animation Principles:
├── 有意义 (Purposeful)
│ └── 每个动画都服务于信息传达
├── 快速 (Fast)
│ └── 动画时长 200-500ms
├── 一致 (Consistent)
│ └── 相同类型动画使用相同参数
└── 克制 (Restrained)
└── 避免过度动画干扰交易
常见问题 FAQ
Q1: Framer Motion 与 CSS Animation 如何选择?
A:
- CSS Animation:简单的 hover、加载动画
- Framer Motion:互动、手势、布局动画、React 状态驱动
Q2: 动画影响性能怎么办?
A: 优化策略:
- 只动画
transform和opacity - 使用
useReducedMotion尊重偏好 - 大量列表使用虚拟化
- 复杂动画使用
layout谨慎
Q3: 如何测试动画?
A: 使用 waitFor 等待动画完成:
import { waitFor } from '@testing-library/react';
it('should animate price change', async () => {
const { getByText } = render(<PriceDisplay price={100} />);
// 触发价格更新
act(() => updatePrice(110));
// 等待动画完成
await waitFor(() => {
expect(getByText('110')).toBeInTheDocument();
});
});
Q4: 动画时长建议?
A:
- 微互动(按钮):100-200ms
- 元素进出:200-300ms
- 页面转场:300-500ms
- 复杂动画:500-800ms
Q5: 如何实现加载骨架动画?
A: 使用 pulse 动画:
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 1.5 }}
className="h-4 w-full rounded bg-muted"
/>
结论与行动建议
Framer Motion 为交易界面带来专业级的动画体验:
- ✅ 声明式 API 易于维护
- ✅ 手势互动提升效率
- ✅ 布局动画自动处理
- ✅ 性能优化内置支持
立即行动
- [ ] 安装 Framer Motion
- [ ] 实现价格闪烁动画
- [ ] 添加页面转场效果
- [ ] 建立动画组件库
- [ ] 设置性能监控
延伸阅读:
作者:Sentinel Team
最后更新:2026-03-04
设计验证:本文基于 Sentinel Bot 实际动画系统经验
正在优化交易界面动画体验?立即体验 Sentinel Bot 的 Framer Motion 驱动界面,或下载我们的动画组件库快速开始。
免费试用 Sentinel Bot | 下载动画组件库 | 设计咨询
相关文章
同系列延伸阅读
- React 18 交易界面 - React 生态整合
- TypeScript 5 类型安全 - 类型安全开发
跨系列推荐
- 交易界面心理 - UI/UX 与交易心理