motion-advanced

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Motion Advanced

Motion 高级动效

Complex, interactive, and physics-based animation patterns. Requires
motion-foundations
to be set up first. Use these when
motion-patterns
is not enough.
复杂、交互式且基于物理的动画模式。 需要先配置
motion-foundations
。 当
motion-patterns
无法满足需求时使用这些模式。

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
  • 构建可拖拽关闭的面板、滑动手势或可重新排序的列表
  • 逐词、逐字符动画文本,或实现实时计数器
  • 绘制SVG路径、变形图标,或制作圆形进度动画
  • 编写自定义动画hook(
    useScrollReveal
    、磁吸按钮、光标跟随器)
  • 使用
    useAnimate
    命令式编排多步骤动画
  • 构建加载 spinner、骨架屏、脉冲指示器或按钮加载状态

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
本技能可实现:
  • 拖拽交互:可拖拽卡片、可拖拽关闭面板、
    Reorder.Group
    列表
  • 手势hooks:滑动检测、长按、捏合轮廓
  • 文本动画组件:单词渐显、字符打字机效果、数字计数器
  • SVG动画:路径绘制、图标变形、描边进度环
  • 自定义hooks:
    useScrollReveal
    useHoverScale
    useNavigationDirection
    useInViewOnce
  • 通过
    useAnimate
    实现的命令式序列,支持中断安全的
    async/await
  • 加载器组件:spinner、骨架屏、脉冲点、进度条、按钮加载状态

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.
  • 基于物理的动效(
    useSpring
    springs.*
    )在直接交互场景下,总是比基于时长的动效更自然。
  • useMotionValue
    +
    useTransform
    可计算派生值,且不会触发重渲染。
  • useAnimate
    序列是命令式且支持中断安全的——在动画执行过程中调用
    animate()
    会自动取消之前的动画。
  • 动效值(
    useMotionValue
    useSpring
    )支持SSR,不会导致 hydration 错误。

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.
  1. 拖拽交互必须在触控设备上测试,不能仅在鼠标设备上测试。
    drag
    属性在两种设备上都可用,但触感和阈值有所不同。
  2. 无限动画必须在
    document.visibilityState === "hidden"
    时暂停
    。后台标签页不得占用GPU/CPU资源。
  3. 滑动阈值必须明确设置。绝不能仅通过速度判断意图;需结合
    offset
    +
    velocity
    检查。
  4. useAnimate
    作用域ref必须挂载到已渲染的DOM元素上
    。在挂载前调用
    animate()
    会静默报错。
  5. 动效值不得在渲染时重新创建。在组件内部使用
    useMotionValue(0)
    是正确的;在渲染中使用
    new MotionValue(0)
    则不正确。
  6. 所有令牌值都从
    motion-foundations
    导入
    。禁止使用内联数字。
  7. 自定义hooks必须处理清理逻辑。每个
    window.addEventListener
    都需要在
    useEffect
    的返回函数中匹配对应的
    removeEventListener
  8. SVG变形需要路径命令数量相等。命令结构不同的路径会直接跳转而非平滑插值。

Decision Guidance

决策指南

Choosing the right advanced API

选择合适的高级API

ScenarioAPI
Drag with physics on release
drag
+
dragTransition: springs.release
Ordered drag-to-reorder list
Reorder.Group
+
Reorder.Item
Dismiss on drag offset
drag="y"
+
onDragEnd
offset check
Swipe left/right
drag="x"
+
onDragEnd
offset check
Long press
useLongPress
hook
Value smoothed over time
useSpring
Value derived from another
useTransform
Multi-step sequence
useAnimate
with
async/await
One-shot imperative animation
animate()
from
motion
Text entering word by wordStagger on
inline-block
spans
SVG drawing on
pathLength
0 → 1
SVG morph
d
attribute tween (equal commands)
Circular progress
strokeDashoffset
tween
场景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
补间动画

When to use
useSpring
vs a spring transition

何时使用
useSpring
vs 弹簧过渡

useSpring
transition: 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
useSpring
transition: springs.*
适用场景光标跟随器、指针跟踪值离散状态变化
更新方式持续更新,每帧触发由状态变化触发
中断处理平滑过渡——物理效果从当前速度继续从当前值重新开始

Core Concepts

核心概念

useMotionValue + useTransform

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
无需重渲染的响应式计算:
tsx
const x = useMotionValue(0)
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0])
// 当x变化时,opacity每帧都会更新——无需setState,也不会触发重渲染

useAnimate

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>
返回
[scope, animate]
。作用域ref必须挂载到DOM元素上。
animate()
调用支持中断安全——在动画执行过程中调用会自动取消之前的运行。
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 })  // 无需等待
}

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}
/>
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()
      }}
    />
  )
}
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) => {
        // 规则3:结合偏移量和速度判断
        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>
  )
}
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  = 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()
  }}
/>
tsx
"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()
  }}
/>

Long press hook

长按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),
  }
}
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>
  )
}
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} />
}
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   // 规则7:清理逻辑
  }, [to])

  return <span ref={nodeRef} />
}

SVG path draw-on

SVG路径绘制

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

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

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

// 使用示例
const { 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 }}
    />
  )
}
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)   // 规则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>
  )
}
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>
  )
}
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} />
}
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 },
      })

    // 规则2:标签页隐藏时暂停
    const handleVisibility = () => {
      if (document.visibilityState === "hidden") controls.stop()
      else void pulse()
    }

    void pulse()
    document.addEventListener("visibilitychange", handleVisibility)
    // 规则7:卸载时停止动画并移除监听器
    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>
  )
}
带骨架屏内容、加载状态和减少动效支持的可拖拽关闭面板——结合
useMotionValue
useTransform
useSafeMotion
AnimatePresence
以及
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 && (
        <>
          {/* 背景遮罩 */}
          <motion.div
            key="backdrop"
            className="fixed inset-0 bg-black/40"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
          />

          {/* 可拖拽关闭面板 */}
          <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
本技能不涵盖以下内容:
  • 令牌和弹簧定义 → 参考
    motion-foundations
  • 标准UI模式(按钮、模态框、 stagger、页面过渡)→ 参考
    motion-patterns
  • 纯CSS动画或不使用
    motion/react
    的Tailwind
    animate-*
  • 基于Canvas或WebGL的动画(Three.js、Pixi等)
  • 结合外部状态管理器的完整拖拽系统(dnd-kit、react-beautiful-dnd)
  • 游戏循环或逐帧动画

Anti-Patterns

反模式

Anti-patternRule violatedFix
drag
tested only on desktop
Rule 1Test on touch emulator and real device
animate={{ repeat: Infinity }}
with no pause
Rule 2Add
visibilitychange
listener
onDragEnd
checking only offset, not velocity
Rule 3Check both
info.offset
and
info.velocity
animate(scope, ...)
before
useEffect
Rule 4Call
animate()
only after mount
const x = new MotionValue(0)
in render
Rule 5Use
const x = useMotionValue(0)
transition={{ duration: 1.2 }}
inline
Rule 6Use
motionTokens.duration.crawl
useEffect
without cleanup
Rule 7Return
removeEventListener
/
controls.stop
SVG morph between paths with different commandsRule 8Normalize path commands before animating
反模式违反的规则修复方案
仅在桌面端测试
drag
规则1在触控模拟器和真实设备上测试
animate={{ repeat: Infinity }}
未设置暂停
规则2添加
visibilitychange
监听器
onDragEnd
仅检查偏移,不检查速度
规则3同时检查
info.offset
info.velocity
useEffect
之前调用
animate(scope, ...)
规则4仅在挂载后调用
animate()
在渲染中使用
const x = new MotionValue(0)
规则5使用
const x = useMotionValue(0)
内联
transition={{ duration: 1.2 }}
规则6使用
motionTokens.duration.crawl
useEffect
未处理清理逻辑
规则7返回
removeEventListener
/
controls.stop
命令结构不同的SVG路径之间进行变形规则8在动画前标准化路径命令

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.
  • motion-foundations
    — 定义了本文中导入的所有令牌、弹簧、
    useSafeMotion
    和SSR防护。使用本技能前必须先配置它。
  • motion-patterns
    — 处理标准UI模式(按钮、模态框、stagger、页面过渡、滚动渐显)。在使用本文的高级模式之前,应优先使用它。