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 與交易心理