Motion Advanced

affaan-m/ECC/pi/core/skills/motion-advanced

作者 affaan-mef648e01899ba3e8dc6371642deaaf64b4477775无许可证275K 个星标收录于 2026年10月9日更新于 2026年10月9日仓库4天前更新

Advanced motion patterns for React / Next.js — drag & drop, gestures, text animations, SVG path drawing, custom hooks, imperative sequences (useAnimate), loaders, and the full API decision tree. Requires motion-foundations. Use when building drag and drop, gestures, text or SVG animation, or imperative animation sequences in React or Next.js.

AI 生成的概览

面向 React 与 Next.js 的高级 Motion 动画模式:拖拽、手势、文字、SVG、自定义钩子与命令式序列。

功能
该技能为使用 Motion 库的 React 与 Next.js 项目提供高级动画指导与代码示例。内容涵盖拖拽交互、手势钩子、文字动画、SVG 路径绘制与变形、自定义钩子、使用 useAnimate 的命令式序列以及加载组件。它还提供 API 选择决策表、规则、反模式和一个综合的端到端示例。该技能仅为说明文档,不附带脚本。
适用场景
适用于在 React 或 Next.js 中构建拖拽、滑动或长按交互、文字或 SVG 动画、自定义动画钩子或命令式动画序列的场景。它假定 motion-foundations 技能已配置完成,并用于超出标准 UI 模式的情况。
运行要求
需要 React 的 Motion 库(motion/react),以及提供令牌、弹簧参数和 useSafeMotion 的 motion-foundations 技能。不包含脚本;智能体需要 React 或 Next.js 项目,并需要网络访问以安装依赖包。

Motion Advanced

Complex, interactive, and physics-based animation patterns. Requires motion-foundations to be set up first. Use these when motion-patterns is not enough.

When to Activate

  • Building drag-to-dismiss sheets, swipe gestures, or reorderable lists
  • Animating text word-by-word, character-by-character, or as a live counter
  • Drawing SVG paths, morphing icons, or animating circular progress
  • Writing a custom animation hook (useScrollReveal, magnetic button, cursor follower)
  • Sequencing multi-step animations imperatively with useAnimate
  • Building spinners, shimmer skeletons, pulse indicators, or loading button states

Outputs

This skill produces:

  • Drag interactions: draggable cards, drag-to-dismiss sheets, Reorder.Group lists
  • Gesture hooks: swipe detection, long press, pinch outline
  • Text animation components: word reveal, character typewriter, number counter
  • SVG animation: path draw-on, icon morph, stroke progress ring
  • Custom hooks: useScrollReveal, useHoverScale, useNavigationDirection, useInViewOnce
  • Imperative sequences via useAnimate with interrupt-safe async/await
  • Loader components: spinner, shimmer, pulse dot, progress bar, button loading state

Principles

  • Physics-based motion (useSpring, springs.*) always feels more natural than duration-based for direct manipulation.
  • useMotionValue + useTransform computes derived values without triggering re-renders.
  • useAnimate sequences are imperative and interrupt-safe — calling animate() mid-flight cancels the previous animation automatically.
  • Motion values (useMotionValue, useSpring) are SSR-safe and do not cause hydration errors.

Rules

  1. Drag interactions must be tested on touch devices, not just mouse. drag prop works on both but feel and threshold differ.
  2. Infinite animations must pause when document.visibilityState === "hidden". Background tabs must not consume GPU/CPU.
  3. Swipe threshold must be explicit. Never infer intent from velocity alone; combine offset + velocity checks.
  4. useAnimate scope ref must be attached to a mounted DOM element. Calling animate() before mount throws silently.
  5. Motion values must not be recreated on render. useMotionValue(0) inside a component body is correct; new MotionValue(0) in a render is not.
  6. All token values are imported from motion-foundations. No inline numbers.
  7. Custom hooks must handle cleanup. Every window.addEventListener needs a matching removeEventListener in the useEffect return.
  8. SVG morphing requires equal path command counts. Paths with different command structures snap instead of interpolating.

Decision Guidance

Choosing the right advanced API

ScenarioAPI
Drag with physics on releasedrag + dragTransition: springs.release
Ordered drag-to-reorder listReorder.Group + Reorder.Item
Dismiss on drag offsetdrag="y" + onDragEnd offset check
Swipe left/rightdrag="x" + onDragEnd offset check
Long pressuseLongPress hook
Value smoothed over timeuseSpring
Value derived from anotheruseTransform
Multi-step sequenceuseAnimate with async/await
One-shot imperative animationanimate() from motion
Text entering word by wordStagger on inline-block spans
SVG drawing onpathLength 0 → 1
SVG morphd attribute tween (equal commands)
Circular progressstrokeDashoffset tween

When to use useSpring vs a spring transition

useSpringtransition: springs.*
Use forCursor follower, pointer-tracked valuesDiscrete state changes
UpdatesContinuous, on every frameTriggered by state change
InterruptSmooth — physics picks up from velocityRestarts from current value

Core Concepts

useMotionValue + useTransform

Reactive computation without re-renders:

tsx
const x = useMotionValue(0)const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0])// opacity updates every frame as x changes — no setState, no re-render

useAnimate

Returns [scope, animate]. The scope ref must be attached to a DOM element. animate() calls are interrupt-safe — calling mid-flight cancels the previous run.

tsx
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>

Code Examples

Draggable card

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

Drag-to-dismiss sheet

tsx
"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) => {        // Rule 3: combine offset + velocity        if (info.offset.y > 120 || info.velocity.y > 500) onClose()      }}    />  )}

Reorderable list

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

Swipe detection

tsx
"use client"import { motion } from "motion/react"
const OFFSET_THRESHOLD  = 50const 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()  }}/>

Long press hook

tsx
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),  }}

Word-by-word reveal

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

Number counter

tsx
"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   // Rule 7: cleanup  }, [to])
  return <span ref={nodeRef} />}

SVG path draw-on

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

Stroke progress ring

tsx
"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

tsx
"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 } }}
// Usageconst { ref, style } = useScrollReveal()<motion.section ref={ref} style={style} />

Cursor follower

tsx
"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)   // Rule 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 skeleton

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

Button loading state

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

Infinite animation with visibility pause

tsx
"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 },      })
    // Rule 2: pause when tab is hidden    const handleVisibility = () => {      if (document.visibilityState === "hidden") controls.stop()      else void pulse()    }
    void pulse()    document.addEventListener("visibilitychange", handleVisibility)    // Rule 7: stop controls and remove listeners on unmount.    return () => {      controls.stop()      document.removeEventListener("visibilitychange", handleVisibility)    }  }, [controls])
  return <motion.span className="w-2 h-2 rounded-full bg-green-400" animate={controls} />}

End-to-End Example

Drag-to-dismiss sheet with shimmer content, loading state, and reduced motion support — combining useMotionValue, useTransform, useSafeMotion, AnimatePresence, and tokens from motion-foundations:

tsx
"use client"import { useState } from "react"import { motion, AnimatePresence, useMotionValue, useTransform } from "motion/react"import { springs, motionTokens } from "@/lib/motion-tokens"import { useSafeMotion } from "@/hooks/use-reduced-motion"import { ShimmerSkeleton } from "./shimmer-skeleton"
export function DismissibleSheet({  isOpen,  onClose,  loading,  children,}: {  isOpen: boolean  onClose: () => void  loading: boolean  children: React.ReactNode}) {  const safe = useSafeMotion(motionTokens.distance.xl)  const y = useMotionValue(0)  const opacity = useTransform(y, [0, 200], [1, 0])
  return (    <AnimatePresence>      {isOpen && (        <>          {/* Backdrop */}          <motion.div            key="backdrop"            className="fixed inset-0 bg-black/40"            initial={{ opacity: 0 }}            animate={{ opacity: 1 }}            exit={{ opacity: 0 }}            onClick={onClose}          />
          {/* Sheet — drag-to-dismiss */}          <motion.div            key="sheet"            className="fixed bottom-0 inset-x-0 rounded-t-2xl bg-white p-6"            drag="y"            dragConstraints={{ top: 0 }}            style={{ y, opacity }}            onDragEnd={(_, info) => {              if (info.offset.y > 120 || info.velocity.y > 500) onClose()            }}            initial={safe.initial}            animate={safe.animate}            exit={safe.exit}            transition={springs.gentle}          >            {loading ? (              <div className="space-y-3">                <ShimmerSkeleton className="h-4 w-3/4" />                <ShimmerSkeleton className="h-4 w-1/2" />                <ShimmerSkeleton className="h-20 w-full" />              </div>            ) : children}          </motion.div>        </>      )}    </AnimatePresence>  )}

Constraints / Non-Goals

This skill does not cover:

  • Token and spring definitions → see motion-foundations
  • Standard UI patterns (button, modal, stagger, page transitions) → see motion-patterns
  • CSS-only animations or Tailwind animate-* without motion/react
  • Canvas or WebGL-based animation (Three.js, Pixi, etc.)
  • Full drag-and-drop systems with external state managers (dnd-kit, react-beautiful-dnd)
  • Game-loop or frame-by-frame animation

Anti-Patterns

Anti-patternRule violatedFix
drag tested only on desktopRule 1Test on touch emulator and real device
animate={{ repeat: Infinity }} with no pauseRule 2Add visibilitychange listener
onDragEnd checking only offset, not velocityRule 3Check both info.offset and info.velocity
animate(scope, ...) before useEffectRule 4Call animate() only after mount
const x = new MotionValue(0) in renderRule 5Use const x = useMotionValue(0)
transition={{ duration: 1.2 }} inlineRule 6Use motionTokens.duration.crawl
useEffect without cleanupRule 7Return removeEventListener / controls.stop
SVG morph between paths with different commandsRule 8Normalize path commands before animating

Related Skills

  • motion-foundations — defines all tokens, springs, useSafeMotion, and SSR guards imported here. Must be set up before using this skill.
  • motion-patterns — handles standard UI patterns (button, modal, stagger, page transitions, scroll reveals). Use it before reaching for the advanced patterns here.

来源与署名

来源:affaan-m/ECC位于pi/core/skills/motion-advanced提交ef648e0

许可证: 无许可证

内容归原作者所有。SourceWeft 从公开仓库中收录这些内容。

举报或申请下架