SKILL.md
唯讀
名稱
motion-advanced
描述
適用於 React / Next.js 的進階動效模式 — 涵蓋拖曳(drag & drop)、手勢、文字動畫、SVG 路徑繪製、自訂 Hooks、命令式序列動畫(useAnimate)、載入器(loaders)以及完整的 API 決策樹。需搭配 motion-foundations 使用。
版本
1.0
Motion Advanced
複雜、高互動性且基於物理的動效模式。
需先完成 motion-foundations 的設定。
當 motion-patterns 無法滿足需求時使用。
何時啟用
- 打造下滑關閉面板(drag-to-dismiss sheets)、滑動手勢(swipe gestures)或可重新排序的清單
- 實現逐字、逐字元顯示的文字動畫,或是動態計數器
- 繪製 SVG 畫筆動效、變形圖示(morphing icons)或圓形進度條
- 撰寫自訂動畫 Hook(如
useScrollReveal、磁吸按鈕、游標跟隨) - 使用
useAnimate以命令式(imperative)方式進行多步驟動畫編排 - 製作載入圖示(spinners)、微光骨架屏(shimmer skeletons)、脈衝指示燈或按鈕載入狀態
輸出內容
此 Skill 包含以下元件與 Hooks:
- 拖曳互動:可拖曳卡片、下滑關閉面板、
Reorder.Group清單 - 手勢 Hooks:滑動偵測、長按、雙指縮放外框(pinch outline)
- 文字動畫元件:逐字揭露、打字機效果、數字計數器
- SVG 動畫:路徑繪製(path draw-on)、圖示變形、邊框進度環
- 自訂 Hooks:
useScrollReveal、useHoverScale、useNavigationDirection、useInViewOnce - 透過
useAnimate實現可中斷且安全的async/await命令式序列動畫 - 載入器元件:spinner、微光 shimmer、脈衝點、進度條、按鈕載入狀態
核心原則
- 在直接互動操控下,基於物理的動效(
useSpring,springs.*)總是比基於時間長度(duration-based)的動畫更自然。 useMotionValue+useTransform能在不觸發元件重新渲染的情況下計算衍生值。useAnimate序列為命令式且支援安全中斷 — 在動畫執行途中再次呼叫animate()會自動取消先前的動畫。- Motion values(
useMotionValue,useSpring)具備 SSR 安全性,不會引發 Hydration 錯誤。
開發規範
- 拖曳互動必須在觸控裝置上進行測試,不能僅在滑鼠上測試。
drag屬性雖同時支援兩者,但體感與門檻值設定有所差異。 - 無限循環動畫必須在
document.visibilityState === "hidden"時暫停。 背景分頁切勿消耗 GPU/CPU 資源。 - 滑動門檻值(Swipe threshold)必須明確指定。 絕不要僅憑速度(velocity)推斷使用者意圖,應結合
offset與velocity一併判定。 useAnimate的 scope ref 必須綁定至已掛載的 DOM 元素。 在掛載前呼叫animate()會靜默拋出例外。- Motion values 不得在渲染過程中重新建立。 在元件本體內使用
useMotionValue(0)是正確的;而在渲染流程中呼叫new MotionValue(0)則屬錯誤做法。 - 所有 Token 數值皆需自
motion-foundations匯入。 請勿使用行內硬編碼數字。 - 自訂 Hooks 必須妥善處理清理機制(cleanup)。 每個
window.addEventListener都必須在useEffect回傳的函式中提供對應的removeEventListener。 - SVG 變形(morphing)需具備相等的路徑指令數量。 若兩路徑的指令結構不同,動畫會直接突變閃爍而非平滑補間。
決策指南
選擇適當的進階 API
| 情境 | API |
|---|---|
| 釋放時帶有物理效果的拖曳 | drag + dragTransition: springs.release |
| 有序的拖曳排序清單 | Reorder.Group + Reorder.Item |
| 依拖曳位移量觸發關閉 | drag="y" + onDragEnd 位移檢查 |
| 左右滑動手勢 | drag="x" + onDragEnd 位移檢查 |
| 長按手勢 | useLongPress hook |
| 隨時間平滑變化的數值 | useSpring |
| 衍生自另一數值的計算值 | useTransform |
| 多步驟動畫序列 | 結合 async/await 的 useAnimate |
| 單次執行的命令式動畫 | 來自 motion 的 animate() |
| 文字逐字進場 | 於 inline-block span 套用 stagger |
| SVG 路徑漸次繪製 | pathLength 0 → 1 |
| SVG 圖示變形 | d 屬性補間動畫(需相等指令數) |
| 圓形進度條 | strokeDashoffset 補間動畫 |
何時使用 useSpring vs 彈簧轉場(Spring Transition)
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]。範疇 ref(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 }) // 觸發後不等待(fire and forget)
}
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 = () => {
i






