Motion 進階 是做什麼的?
複雜、互動且基於物理的動畫模式。
需先設定好 motion-foundations。
當 motion-patterns 不夠用時,使用這些模式。
啟用時機
- 建置拖曳關閉面板、滑動手勢或可重新排序列表
- 逐字、逐字元動畫文字,或作為即時計數器
- 繪製 SVG 路徑、變形圖示或動畫化圓形進度
- 撰寫自訂動畫 Hook(
useScrollReveal、磁性按鈕、游標跟隨器) - 使用
useAnimate指令式地排序多步驟動畫 - 建置載入旋轉器、微光骨架、脈衝指示器或按鈕載入狀態
產出
此技能可產出:
- 拖曳互動:可拖曳卡片、拖曳關閉面板、
Reorder.Group列表 - 手勢 Hook:滑動偵測、長按、縮放手勢大綱
- 文字動畫元件:逐詞揭示、打字機效果、數字計數器
- SVG 動畫:路徑繪製、圖示變形、筆畫進度環
- 自訂 Hook:
useScrollReveal、useHoverScale、useNavigationDirection、useInViewOnce - 使用
useAnimate的指令式序列,搭配可中斷安全的async/await - 載入器元件:旋轉器、微光、脈衝點、進度條、按鈕載入狀態
原則
- 基於物理的動畫(
useSpring、springs.*)在直接操作時總是比基於持續時間的更自然。 useMotionValue+useTransform可計算衍生值而不觸發重新渲染。useAnimate序列是指令式且可中斷安全的 — 在中途呼叫animate()會自動取消先前的動畫。- 運動值(
useMotionValue、useSpring)是 SSR 安全的,不會導致水合錯誤。
規則
- 拖曳互動必須在觸控裝置上測試,而不僅是滑鼠。
drag屬性在兩者上皆可運作,但手感與閾值不同。 - 無限動畫必須在
document.visibilityState === "hidden"時暫停。 背景分頁不得消耗 GPU/CPU。 - 滑動閾值必須明確指定。 切勿僅憑速度推斷意圖;應結合
offset+velocity檢查。 useAnimate的作用域 ref 必須附加到已掛載的 DOM 元素上。 在掛載前呼叫animate()會靜默失敗。- 運動值不得在渲染時重新建立。 在元件主體內使用
useMotionValue(0)是正確的;在渲染中使用new MotionValue(0)則否。 - 所有記號值皆從
motion-foundations匯入。 不使用內聯數字。 - 自訂 Hook 必須處理清理作業。 每個
window.addEventListener都需要在useEffect回傳中有一個對應的removeEventListener。 - SVG 變形需要相等的路徑指令數量。 具有不同指令結構的路徑會產生跳躍而非插值。
決策指引
選擇正確的進階 API
| 情境 | API |
|---|---|
| 釋放時帶物理效果的拖曳 | drag + dragTransition: springs.release |
| 有序拖曳重新排序列表 | Reorder.Group + Reorder.Item |
| 拖曳偏移關閉 | drag="y" + onDragEnd 偏移檢查 |
| 向左/向右滑動 | drag="x" + onDragEnd 偏移檢查 |
| 長按 | useLongPress Hook |
| 隨時間平滑的值 | useSpring |
| 從另一個值衍生 | useTransform |
| 多步驟序列 | useAnimate 搭配 async/await |
| 一次性指令式動畫 | 來自 motion 的 animate() |
| 文字逐詞進入 | 在 inline-block 範圍上交錯 |
| SVG 繪製開啟 | pathLength 從 0 到 1 |
| SVG 變形 | d 屬性補間(相等指令數) |
| 圓形進度 | strokeDashoffset 補間 |
何時使用 useSpring 與彈簧轉場
useSpring | transition: springs.* | |
|---|---|---|
| 用於 | 游標跟隨器、指標追蹤值 | 離散狀態變更 |
| 更新 | 連續,每幀更新 | 由狀態變更觸發 |
| 中斷 | 平滑 — 物理從速度接續 | 從目前值重新開始 |
核心概念
useMotionValue + useTransform
無重新渲染的反應式計算:
const x = useMotionValue(0)
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0])
// opacity 隨 x 變化每幀更新 — 無 setState,無重新渲染
useAnimate
回傳 [scope, animate]。scope ref 必須附加到 DOM 元素上。
animate() 呼叫是可中斷安全的 — 在中途呼叫會取消先前的執行。
const [scope, animate] = useAnimate()
async function play() {
await animate(".step-1", { opacity: 1 }, { duration: 0.3 })
await animate(".step-2", { x: 0 }, { duration: 0.4 })
animate(".step-3", { scale: 1 }, { duration: 0.25 }) // 發射後忘記
}
return <div ref={scope}>...</div>
程式碼範例
可拖曳卡片
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.div
drag
dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }}
dragElastic={0.1}
whileDrag={{
scale: motionTokens.scale.pop,
boxShadow: "0 16px 40px rgba(0,0,0,0.2)",
}}
dragTransition={springs.release}
/>
拖曳關閉面板
"use client"
import { motion, useMotionValue, useTransform } from "motion/react"
export function BottomSheet({ onClose }: { onClose: () => void }) {
const y = useMotionValue(0)
const opacity = useTransform(y, [0, 200], [1, 0])
return (
<motion.div
drag="y"
dragConstraints={{ top: 0 }}
style={{ y, opacity }}
onDragEnd={(_, info) => {
// 規則 3:結合 offset + velocity
if (info.offset.y > 120 || info.velocity.y > 500) onClose()
}}
/>
)
}
可重新排序列表
"use client"
import { Reorder } from "motion/react"
export function SortableList() {
const [items, setItems] = useState(initialItems)
return (
<Reorder.Group axis="y" values={items} onReorder={setItems}>
{items.map((item) => (
<Reorder.Item key={item.id} value={item}>
{item.label}
</Reorder.Item>
))}
</Reorder.Group>
)
}
滑動偵測
"use client"
import { motion } from "motion/react"
const OFFSET_THRESHOLD = 50
const VELOCITY_THRESHOLD = 300
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
onDragEnd={(_, info) => {
const swipedRight = info.offset.x > OFFSET_THRESHOLD || info.velocity.x > VELOCITY_THRESHOLD
const swipedLeft = info.offset.x < -OFFSET_THRESHOLD || info.velocity.x < -VELOCITY_THRESHOLD
if (swipedRight) onSwipeRight()
if (swipedLeft) onSwipeLeft()
}}
/>
長按 Hook
import { useRef } from "react"
export function useLongPress(callback: () => void, ms = 600) {
const timerRef = useRef<ReturnType<typeof setTimeout>>()
return {
onPointerDown: () => { timerRef.current = setTimeout(callback, ms) },
onPointerUp: () => clearTimeout(timerRef.current),
onPointerLeave: () => clearTimeout(timerRef.current),
}
}
逐詞揭示
"use client"
import { motion } from "motion/react"
import { springs } from "@/lib/motion-tokens"
export function AnimatedText({ text }: { text: string }) {
return (
<motion.p
variants={{ visible: { transition: { staggerChildren: 0.05 } } }}
initial="hidden"
animate="visible"
>
{text.split(" ").map((word, i) => (
<motion.span
key={i}
className="inline-block mr-1"
variants={{
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: springs.gentle },
}}
>
{word}
</motion.span>
))}
</motion.p>
)
}
數字計數器
"use client"
import { useRef, useEffect } from "react"
import { animate } from "motion"
import { motionTokens } from "@/lib/motion-tokens"
export function Counter({ to }: { to: number }) {
const nodeRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
const controls = animate(0, to, {
duration: motionTokens.duration.crawl,
ease: motionTokens.easing.smooth,
onUpdate: (v) => {
if (nodeRef.current) nodeRef.current.textContent = Math.round(v).toString()
},
})
return controls.stop // 規則 7:清理
}, [to])
return <span ref={nodeRef} />
}
SVG 路徑繪製
"use client"
import { motion } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
<motion.path
d="M 0 100 Q 50 0 100 100"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>
筆畫進度環
"use client"
import { motion } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
const CIRCUMFERENCE = 2 * Math.PI * 40 // r=40
export function ProgressRing({ progress }: { progress: number }) {
return (
<svg width="100" height="100" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="#e5e7eb" strokeWidth="8" />
<motion.circle
cx="50" cy="50" r="40"
fill="none" stroke="#6366f1" strokeWidth="8"
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
animate={{ strokeDashoffset: CIRCUMFERENCE - (progress / 100) * CIRCUMFERENCE }}
transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }}
style={{ rotate: -90, transformOrigin: "center" }}
/>
</svg>
)
}
useScrollReveal Hook
"use client"
import { useRef } from "react"
import { useScroll, useTransform } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function useScrollReveal() {
const ref = useRef(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
const opacity = useTransform(scrollYProgress, [0, 0.3], [0, 1])
const y = useTransform(scrollYProgress, [0, 0.3], [motionTokens.distance.lg, 0])
return { ref, style: { opacity, y } }
}
// 使用方式
const { ref, style } = useScrollReveal()
<motion.section ref={ref} style={style} />
游標跟隨器
"use client"
import { useEffect } from "react"
import { motion, useMotionValue, useSpring } from "motion/react"
import { springs } from "@/lib/motion-tokens"
export function CursorFollower() {
const x = useMotionValue(-100)
const y = useMotionValue(-100)
const sx = useSpring(x, springs.gentle)
const sy = useSpring(y, springs.gentle)
useEffect(() => {
const move = (e: MouseEvent) => { x.set(e.clientX); y.set(e.clientY) }
window.addEventListener("mousemove", move)
return () => window.removeEventListener("mousemove", move) // 規則 7
}, [])
return (
<motion.div
className="fixed top-0 left-0 w-6 h-6 rounded-full bg-indigo-500
pointer-events-none -translate-x-1/2 -translate-y-1/2 z-50"
style={{ x: sx, y: sy }}
/>
)
}
微光骨架
"use client"
import { useEffect } from "react"
import { motion, useAnimation } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function ShimmerSkeleton({ className = "" }: { className?: string }) {
const controls = useAnimation()
useEffect(() => {
const play = () =>
controls.start({
x: ["-100%", "100%"],
transition: {
repeat: Infinity,
duration: motionTokens.duration.crawl,
ease: motionTokens.easing.linear,
},
})
const handleVisibility = () => {
if (document.visibilityState === "hidden") controls.stop()
else void play()
}
void play()
document.addEventListener("visibilitychange", handleVisibility)
return () => {
controls.stop()
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [controls])
return (
<div className={`relative overflow-hidden bg-gray-200 rounded ${className}`}>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/60 to-transparent"
initial={{ x: "-100%" }}
animate={controls}
/>
</div>
)
}
按鈕載入狀態
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
export function LoadingButton({
loading,
label,
onClick,
}: {
loading: boolean
label: string
onClick: () => void
}) {
return (
<motion.button
onClick={onClick}
animate={{ opacity: loading ? 0.7 : 1 }}
whileTap={loading ? {} : { scale: motionTokens.scale.press }}
transition={springs.snappy}
disabled={loading}
>
<AnimatePresence mode="wait">
{loading ? (
<motion.span
key="loading"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
…
</motion.span>
) : (
<motion.span
key="label"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
{label}
</motion.span>
)}
</AnimatePresence>
</motion.button>
)
}
含可見性暫停的無限動畫
"use client"
import { useEffect } from "react"
import { motion, useAnimation } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function PulseDot() {
const controls = useAnimation()
useEffect(() => {
const pulse = () =>
controls.start({
scale: [1, 1.4, 1],
opacity: [1, 0.6, 1],
transition: { repeat: Infinity, duration: motionTokens.duration.crawl },
})
// 規則 2:分頁隱藏時暫停
const handleVisibility = () => {
if (document.visibilityState === "hidden") {
controls.stop()
} else {
void pulse()
}
}
void pulse()
document.addEventListener("visibilitychange", handleVisibility)
return () => {
controls.stop()
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [controls])
return (
<motion.div animate={controls} className="w-4 h-4 rounded-full bg-indigo-500" />
)
}
Magnetic button (可選)
"use client"
import { useRef, useCallback } from "react"
import { motion, useMotionValue, useSpring } from "motion/react"
import { springs } from "@/lib/motion-tokens"
export function MagneticButton({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
const sx = useSpring(x, springs.gentle)
const sy = useSpring(y, springs.gentle)
const handlePointerMove = useCallback((e: React.PointerEvent) => {
const el = ref.current
if (!el) return
const rect = el.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const dx = (e.clientX - cx) * 0.3
const dy = (e.clientY - cy) * 0.3
x.set(dx)
y.set(dy)
}, [x, y])
const handlePointerLeave = useCallback(() => {
x.set(0)
y.set(0)
}, [x, y])
return (
<motion.div
ref={ref}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
style={{ x: sx, y: sy }}
className="inline-block"
>
{children}
</motion.div>
)
}
所有上述範例皆使用來自 motion-foundations 的匯入。
所有自訂 Hook 都透過 useEffect 回傳中的 removeEventListener 清理監聽器。
所有無限動畫都會在背景分頁中暫停。