motion-advanced

motion-advanced

熱門

適用於 React / Next.js 的進階動效模式 — 涵蓋拖曳(drag & drop)、手勢、文字動畫、SVG 路徑繪製、自訂 Hooks、命令式序列動畫(useAnimate)、載入器(loaders)以及完整的 API 決策樹。需搭配 motion-foundations 使用。

24萬星標
3.6萬分支
更新於 2026/8/3
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:useScrollRevealuseHoverScaleuseNavigationDirectionuseInViewOnce
  • 透過 useAnimate 實現可中斷且安全的 async/await 命令式序列動畫
  • 載入器元件:spinner、微光 shimmer、脈衝點、進度條、按鈕載入狀態

核心原則

  • 在直接互動操控下,基於物理的動效(useSpring, springs.*)總是比基於時間長度(duration-based)的動畫更自然。
  • useMotionValue + useTransform 能在不觸發元件重新渲染的情況下計算衍生值。
  • useAnimate 序列為命令式且支援安全中斷 — 在動畫執行途中再次呼叫 animate() 會自動取消先前的動畫。
  • Motion values(useMotionValue, useSpring)具備 SSR 安全性,不會引發 Hydration 錯誤。

開發規範

  1. 拖曳互動必須在觸控裝置上進行測試,不能僅在滑鼠上測試。drag 屬性雖同時支援兩者,但體感與門檻值設定有所差異。
  2. 無限循環動畫必須在 document.visibilityState === "hidden" 時暫停。 背景分頁切勿消耗 GPU/CPU 資源。
  3. 滑動門檻值(Swipe threshold)必須明確指定。 絕不要僅憑速度(velocity)推斷使用者意圖,應結合 offsetvelocity 一併判定。
  4. useAnimate 的 scope ref 必須綁定至已掛載的 DOM 元素。 在掛載前呼叫 animate() 會靜默拋出例外。
  5. Motion values 不得在渲染過程中重新建立。 在元件本體內使用 useMotionValue(0) 是正確的;而在渲染流程中呼叫 new MotionValue(0) 則屬錯誤做法。
  6. 所有 Token 數值皆需自 motion-foundations 匯入。 請勿使用行內硬編碼數字。
  7. 自訂 Hooks 必須妥善處理清理機制(cleanup)。 每個 window.addEventListener 都必須在 useEffect 回傳的函式中提供對應的 removeEventListener
  8. SVG 變形(morphing)需具備相等的路徑指令數量。 若兩路徑的指令結構不同,動畫會直接突變閃爍而非平滑補間。

決策指南

選擇適當的進階 API

情境 API
釋放時帶有物理效果的拖曳 drag + dragTransition: springs.release
有序的拖曳排序清單 Reorder.Group + Reorder.Item
依拖曳位移量觸發關閉 drag="y" + onDragEnd 位移檢查
左右滑動手勢 drag="x" + onDragEnd 位移檢查
長按手勢 useLongPress hook
隨時間平滑變化的數值 useSpring
衍生自另一數值的計算值 useTransform
多步驟動畫序列 結合 async/awaituseAnimate
單次執行的命令式動畫 來自 motionanimate()
文字逐字進場 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