motion-advanced

motion-advanced

热门

React / Next.js 高阶动效设计模式——涵盖拖拽交互、手势操作、文本动画、SVG 路径绘制、自定义 Hook、命令式序列动画(useAnimate)、加载器(Loaders)及完整的 API 选型决策树。需先配置 motion-foundations。

24万Star
3.6万Fork
更新于 2026/8/3
SKILL.md
只读
名称
motion-advanced
描述

React / Next.js 高阶动效设计模式——涵盖拖拽交互、手势操作、文本动画、SVG 路径绘制、自定义 Hook、命令式序列动画(useAnimate)、加载器(Loaders)及完整的 API 选型决策树。需先配置 motion-foundations。

版本
1.0

Motion Advanced(进阶动效)

复杂、高交互性且基于物理引擎的动画模式。
使用前需先配置 motion-foundations
motion-patterns 无法满足需求时使用此 Skill。

触发时机 / 使用场景

  • 构建拖拽松手关闭的 Bottom Sheet、滑动手势或可重排序列表
  • 实现逐字、逐字符进入的文本动画,或实时数字计数器
  • 绘制 SVG 路径、图标形变(Morphing)或环形进度条动画
  • 编写自定义动画 Hook(如 useScrollReveal、磁吸按钮、跟随光标)
  • 使用 useAnimate 以命令式方式编排多阶段序列动画
  • 构建 Spinner 加载圈、骨架屏微光 shimmer、脉冲指示器或按钮加载状态

产出内容

此 Skill 可生成以下内容:

  • 拖拽交互:可拖拽卡片、拖拽下滑关闭组件、Reorder.Group 可排序列表
  • 手势 Hook:滑动检测(Swipe)、长按(Long Press)、双指缩放框架
  • 文本动画组件:逐字显现、打字机效果、数字动态计数器
  • SVG 动画:路径描边绘制、图标形变、描边环形进度条
  • 自定义 Hook:useScrollRevealuseHoverScaleuseNavigationDirectionuseInViewOnce
  • 基于 useAnimate 的命令式动画序列(支持打断安全的 async/await 异步控制)
  • 加载器组件:Spinner、微光骨架屏(Shimmer)、脉冲圆点、进度条、按钮加载态

核心原则

  • 对于直接手势操控,基于物理引擎的动效(useSpringsprings.*)永远比指定时长(duration-based)的动画更自然顺滑。
  • useMotionValue + useTransform 可以在不触发组件 Re-render(重新渲染)的前提下计算衍生值。
  • useAnimate 序列是命令式且支持打断安全的——在动画运行途中再次调用 animate() 会自动取消之前的动画。
  • Motion value(如 useMotionValueuseSpring)天然兼容 SSR(服务端渲染),不会引发 Hydration(水合)报错。

硬性规则

  1. 拖拽交互必须在触摸设备上实测,不能只用鼠标测试。drag 属性虽同时支持两者,但手感和阈值感知存在差异。
  2. 无限循环动画必须在页面不可见时(document.visibilityState === "hidden")暂停。切到后台标签页时严禁占用 CPU/GPU 资源。
  3. 滑动手势(Swipe)阈值必须明确。切勿仅凭速度判断用户意图,需结合 offset(偏移量)与 velocity(速度)共同校验。
  4. useAnimate 的 scope ref 必须挂载到已 Render 的 DOM 元素上。在 Mount 前调用 animate() 会静默报错。
  5. Motion value 严禁在渲染过程中重复创建。在组件体中使用 useMotionValue(0) 是正确的;在 render 中写 new MotionValue(0) 则属错误用法。
  6. 所有 Token 必须从 motion-foundations 导入,严禁硬编码内联数字。
  7. 自定义 Hook 必须做好副作用清理(Cleanup)。在 useEffect 中添加的每个 window.addEventListener,返回值里都必须有对应的 removeEventListener
  8. SVG 图形变形(Morphing)要求路径命令数量一致。如果两条路径的命令结构不同,动画会发生突变卡顿(snap)而不是平滑插值过渡。

决策指南

进阶 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

响应式计算(不触发组件 Re-render):

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}
/>

拖拽下滑关闭 Sheet

"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 }}
/>

SVG 描边环形进度条

"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 }}
    />
  )
}

Shimmer 微光骨架屏

"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

<!-- truncated for translation batch; full body continues in source -->