motion-advanced
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMotion Advanced
Motion 高级动效
Complex, interactive, and physics-based animation patterns.
Requires to be set up first.
Use these when is not enough.
motion-foundationsmotion-patterns复杂、交互式且基于物理的动画模式。
需要先配置。
当无法满足需求时使用这些模式。
motion-foundationsmotion-patternsWhen 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 (, magnetic button, cursor follower)
useScrollReveal - 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, lists
Reorder.Group - 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,useNavigationDirectionuseInViewOnce - Imperative sequences via with interrupt-safe
useAnimateasync/await - Loader components: spinner, shimmer, pulse dot, progress bar, button loading state
本技能可实现:
- 拖拽交互:可拖拽卡片、可拖拽关闭面板、列表
Reorder.Group - 手势hooks:滑动检测、长按、捏合轮廓
- 文本动画组件:单词渐显、字符打字机效果、数字计数器
- SVG动画:路径绘制、图标变形、描边进度环
- 自定义hooks:、
useScrollReveal、useHoverScale、useNavigationDirectionuseInViewOnce - 通过实现的命令式序列,支持中断安全的
useAnimateasync/await - 加载器组件:spinner、骨架屏、脉冲点、进度条、按钮加载状态
Principles
设计原则
- Physics-based motion (,
useSpring) always feels more natural than duration-based for direct manipulation.springs.* - +
useMotionValuecomputes derived values without triggering re-renders.useTransform - sequences are imperative and interrupt-safe — calling
useAnimatemid-flight cancels the previous animation automatically.animate() - Motion values (,
useMotionValue) are SSR-safe and do not cause hydration errors.useSpring
- 基于物理的动效(、
useSpring)在直接交互场景下,总是比基于时长的动效更自然。springs.* - +
useMotionValue可计算派生值,且不会触发重渲染。useTransform - 序列是命令式且支持中断安全的——在动画执行过程中调用
useAnimate会自动取消之前的动画。animate() - 动效值(、
useMotionValue)支持SSR,不会导致 hydration 错误。useSpring
Rules
规则要求
- Drag interactions must be tested on touch devices, not just mouse. prop works on both but feel and threshold differ.
drag - Infinite animations must pause when . Background tabs must not consume GPU/CPU.
document.visibilityState === "hidden" - Swipe threshold must be explicit. Never infer intent from velocity alone; combine +
offsetchecks.velocity - scope ref must be attached to a mounted DOM element. Calling
useAnimatebefore mount throws silently.animate() - Motion values must not be recreated on render. inside a component body is correct;
useMotionValue(0)in a render is not.new MotionValue(0) - All token values are imported from . No inline numbers.
motion-foundations - Custom hooks must handle cleanup. Every needs a matching
window.addEventListenerin theremoveEventListenerreturn.useEffect - SVG morphing requires equal path command counts. Paths with different command structures snap instead of interpolating.
- 拖拽交互必须在触控设备上测试,不能仅在鼠标设备上测试。属性在两种设备上都可用,但触感和阈值有所不同。
drag - 无限动画必须在时暂停。后台标签页不得占用GPU/CPU资源。
document.visibilityState === "hidden" - 滑动阈值必须明确设置。绝不能仅通过速度判断意图;需结合+
offset检查。velocity - 作用域ref必须挂载到已渲染的DOM元素上。在挂载前调用
useAnimate会静默报错。animate() - 动效值不得在渲染时重新创建。在组件内部使用是正确的;在渲染中使用
useMotionValue(0)则不正确。new MotionValue(0) - 所有令牌值都从导入。禁止使用内联数字。
motion-foundations - 自定义hooks必须处理清理逻辑。每个都需要在
window.addEventListener的返回函数中匹配对应的useEffect。removeEventListener - SVG变形需要路径命令数量相等。命令结构不同的路径会直接跳转而非平滑插值。
Decision Guidance
决策指南
Choosing the right advanced API
选择合适的高级API
| Scenario | API |
|---|---|
| Drag with physics on release | |
| Ordered drag-to-reorder list | |
| Dismiss on drag offset | |
| Swipe left/right | |
| Long press | |
| Value smoothed over time | |
| Value derived from another | |
| Multi-step sequence | |
| One-shot imperative animation | |
| Text entering word by word | Stagger on |
| SVG drawing on | |
| SVG morph | |
| Circular progress | |
| 场景 | API |
|---|---|
| 释放时带物理效果的拖拽 | |
| 可拖拽重排序的有序列表 | |
| 拖拽偏移时关闭 | |
| 左右滑动 | |
| 长按 | |
| 随时间平滑变化的值 | |
| 从其他值派生的值 | |
| 多步骤序列 | 使用 |
| 一次性命令式动画 | 来自 |
| 逐词进入的文本 | 对 |
| SVG路径绘制 | |
| SVG变形 | |
| 圆形进度条 | |
When to use useSpring
vs a spring transition
useSpring何时使用useSpring
vs 弹簧过渡
useSpring | | |
|---|---|---|
| Use for | Cursor follower, pointer-tracked values | Discrete state changes |
| Updates | Continuous, on every frame | Triggered by state change |
| Interrupt | Smooth — physics picks up from velocity | Restarts from current value |
| | |
|---|---|---|
| 适用场景 | 光标跟随器、指针跟踪值 | 离散状态变化 |
| 更新方式 | 持续更新,每帧触发 | 由状态变化触发 |
| 中断处理 | 平滑过渡——物理效果从当前速度继续 | 从当前值重新开始 |
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 . The scope ref must be attached to a DOM element.
calls are interrupt-safe — calling mid-flight cancels the previous run.
[scope, animate]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 }) // fire and forget
}
return <div ref={scope}>...</div>返回。作用域ref必须挂载到DOM元素上。调用支持中断安全——在动画执行过程中调用会自动取消之前的运行。
[scope, animate]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 , , ,
, and tokens from :
useMotionValueuseTransformuseSafeMotionAnimatePresencemotion-foundationstsx
"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>
)
}带骨架屏内容、加载状态和减少动效支持的可拖拽关闭面板——结合、、、以及中的令牌:
useMotionValueuseTransformuseSafeMotionAnimatePresencemotion-foundationstsx
"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 without
animate-*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动画或不使用的Tailwind
motion/reactanimate-* - 基于Canvas或WebGL的动画(Three.js、Pixi等)
- 结合外部状态管理器的完整拖拽系统(dnd-kit、react-beautiful-dnd)
- 游戏循环或逐帧动画
Anti-Patterns
反模式
| Anti-pattern | Rule violated | Fix |
|---|---|---|
| Rule 1 | Test on touch emulator and real device |
| Rule 2 | Add |
| Rule 3 | Check both |
| Rule 4 | Call |
| Rule 5 | Use |
| Rule 6 | Use |
| Rule 7 | Return |
| SVG morph between paths with different commands | Rule 8 | Normalize path commands before animating |
| 反模式 | 违反的规则 | 修复方案 |
|---|---|---|
仅在桌面端测试 | 规则1 | 在触控模拟器和真实设备上测试 |
| 规则2 | 添加 |
| 规则3 | 同时检查 |
在 | 规则4 | 仅在挂载后调用 |
在渲染中使用 | 规则5 | 使用 |
内联 | 规则6 | 使用 |
| 规则7 | 返回 |
| 命令结构不同的SVG路径之间进行变形 | 规则8 | 在动画前标准化路径命令 |
Related Skills
相关技能
- — defines all tokens, springs,
motion-foundations, and SSR guards imported here. Must be set up before using this skill.useSafeMotion - — handles standard UI patterns (button, modal, stagger, page transitions, scroll reveals). Use it before reaching for the advanced patterns here.
motion-patterns
- — 定义了本文中导入的所有令牌、弹簧、
motion-foundations和SSR防护。使用本技能前必须先配置它。useSafeMotion - — 处理标准UI模式(按钮、模态框、stagger、页面过渡、滚动渐显)。在使用本文的高级模式之前,应优先使用它。
motion-patterns