教學 專家

Framer Motion 交易介面動畫設計指南|React 動畫與使用者體驗優化實戰

Sentinel Team · 2026-03-04
Framer Motion 交易介面動畫設計指南|React 動畫與使用者體驗優化實戰

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:

Q2: 動畫影響效能怎麼辦?

A: 優化策略:

  1. 只動畫 transformopacity
  2. 使用 useReducedMotion 尊重偏好
  3. 大量列表使用虛擬化
  4. 複雜動畫使用 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:

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 為交易介面帶來專業級的動畫體驗:

立即行動


延伸閱讀


作者:Sentinel Team

最後更新:2026-03-04

設計驗證:本文基於 Sentinel Bot 實際動畫系統經驗


正在優化交易介面動畫體驗?立即體驗 Sentinel Bot 的 Framer Motion 驅動介面,或下載我們的動畫組件庫快速開始。

免費試用 Sentinel Bot | 下載動畫組件庫 | 設計諮詢


相關文章

同系列延伸閱讀

跨系列推薦