关于 framer-motion
面向 React 和 Next.js 开发者的 Motion(Framer Motion)动画实现指南,介绍库的常用模式、性能注意事项及无障碍实践。
主要功能
- 01滚动触发与交错动画
- 02布局变化和退出动画
- 03手势、拖动与弹簧动画
- 04MotionValues 帧更新
- 05减少动态效果偏好支持
适用场景
- 01为页面元素添加滚动入场效果
- 02实现条件渲染内容的退出动画
- 03构建可拖动或手势交互组件
- 04制作滚动进度条或计数器
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ schoepplake/ framer-motion-skill --skill framer-motion安装后在 Claude Code 中输入/framer-motion确认已加载。
详细介绍
Motion(Framer Motion)——动画技能
适用于 React 和 Next.js 的生产级动画模式。本技能可帮助你使用 Motion 库(v12+)编写正确、高性能且易于访问的动画。
导入
Motion 已从 framer-motion 更名为 motion。两个包名都可用,但导入路径很重要:
// Client Components (standard React)
import { motion, AnimatePresence } from "motion/react"
// Next.js Server Components — use the client export
import * as motion from "motion/react-client"
// Legacy import (still works with the framer-motion package)
import { motion } from "framer-motion"
如果项目已将 framer-motion 用作依赖项,请继续使用 "framer-motion" 导入以保持一致。不要混用导入来源。
核心概念
motion.* 组件
每个 HTML/SVG 元素都有对应的 motion 组件。这些组件接受动画属性:
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
/>
重要:motion.* 组件是客户端组件。在 Next.js App Router 中,请为文件添加 "use client",或将它们包装在客户端组件中。
MotionValues——无需重新渲染即可生成动画
useMotionValue 会创建不会触发 React 重新渲染的更新值。这是实现 60fps 动画的关键:
const x = useMotionValue(0)
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0])
return <motion.div style={{ x, opacity }} drag="x" />
规则:
- 对每帧都会变化的值(滚动位置、拖动位置、连续动画)使用
useMotionValue - 使用
useTransform从其他 MotionValues 派生值(不会重新渲染) - 切勿使用
useState进行逐帧更新——这会导致每帧都重新渲染
过渡
控制动画在不同状态之间的变化方式:
// Spring (default for physical properties like x, y, scale)
transition={{ type: "spring", stiffness: 300, damping: 30 }}
// Tween (default for non-physical properties like opacity, color)
transition={{ type: "tween", duration: 0.3, ease: "easeInOut" }}
// Custom cubic bezier
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
常见模式
滚动触发淡入
最常见的动画模式。使用 whileInView——不要手动使用 IntersectionObserver:
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.1 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
>
{children}
</motion.div>
viewport.once: true——只在首次进入视口时播放动画(营销页面的标准做法)viewport.amount——元素必须有多少部分可见(0.1 = 10%)viewport.margin——扩展触发区域(例如,"0px 0px 50px 0px")
子元素依次播放动画
使用 variants 让子元素依次播放动画:
const container = {
hidden: {},
show: { transition: { staggerChildren: 0.1 } },
}
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
}
<motion.ul variants={container} initial="hidden" whileInView="show" viewport={{ once: true }}>
{items.map((i) => (
<motion.li key={i} variants={item}>{i}</motion.li>
))}
</motion.ul>
Variants 会向下传播——父级状态变化会自动传递给子元素。
使用 AnimatePresence 播放退出动画
将按条件渲染的元素包装起来,使其在移除前播放退出动画:
<AnimatePresence>
{isOpen && (
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
规则:
- 子元素必须有唯一的
key exit属性用于定义退出动画- 使用
mode="wait",先完成退出动画,再进入下一个元素 - 使用
mode="popLayout"实现支持布局的过渡
与滚动位置关联的动画
让动画直接与滚动位置关联:
const { scrollYProgress } = useScroll()
const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0])
const scale = useTransform(scrollYProgress, [0, 1], [1, 0.8])
return <motion.div style={{ opacity, scale }} />
针对特定元素进行滚动跟踪:
const ref = useRef(null)
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"], // when element enters/exits viewport
})
平滑的弹簧值
使用 useSpring 实现 MotionValues 的顺滑过渡:
const scrollY = useMotionValue(0)
const smoothY = useSpring(scrollY, { stiffness: 100, damping: 30 })
连续动画(useAnimationFrame)
适用于每帧都运行的动画(渐变闪烁、计数等):
const progress = useMotionValue(0)
useAnimationFrame((time, delta) => {
const newValue = (time / 1000) % 100
progress.set(newValue)
})
const backgroundPosition = useTransform(progress, (p) => `${p}% 50%`)
return <motion.span style={{ backgroundPosition }} />
布局动画
自动为布局变化播放动画:
// Simple layout animation
<motion.div layout />
// Shared layout animation between components
<motion.div layoutId="hero-image" />
如果两个不同组件中存在相同的 layoutId,Motion 会在它们之间播放动画(例如,从缩略图过渡到全屏视图)。
手势动画
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.97 }}
whileFocus={{ outline: "2px solid #5227FF" }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
/>
拖动
<motion.div
drag // enable both axes
drag="x" // constrain to x-axis
dragConstraints={{ left: -100, right: 100 }}
dragElastic={0.2}
onDragEnd={(e, info) => {
if (info.offset.x > 100) handleSwipeRight()
}}
/>
无障碍
useReducedMotion
始终尊重用户的动态效果偏好。这不是可选项,而是无障碍要求:
import { useReducedMotion } from "framer-motion"
function AnimatedComponent({ children }) {
const prefersReducedMotion = useReducedMotion()
if (prefersReducedMotion) {
return <div>{children}</div>
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
>
{children}
</motion.div>
)
}
模式:检查 useReducedMotion(),然后完全跳过动画,或将动画简化为仅改变不透明度(不移动)。
性能规则
-
切勿为
width、height、top、left播放动画——这些属性会触发布局重新计算。请改用transform属性(x、y、scale、rotate)。 -
使用 MotionValues 进行逐帧更新——
useState会导致每帧都重新渲染。MotionValues 会直接更新 DOM。 -
will-change: transform会由 motion 组件自动添加——不要手动添加。 -
layout动画开销较大——请有选择地使用,不要用于每个元素。 -
避免为
box-shadow播放动画——改用filter: drop-shadow(),或为伪元素阴影的不透明度播放动画。 -
优先使用
opacity和transform——它们由 GPU 合成,并在单独的线程上运行。 -
**对于交互元素(悬停、轻触、拖动),弹簧过渡比 tween/easing 更自然。**将 tween 保留用于滚动触发的入场动画。
常见错误
| 错误 | 修正方法 |
|---|---|
用 useState 保存拖动位置 | 使用 useMotionValue |
手动使用 IntersectionObserver | 使用 whileInView 属性 |
使用 setTimeout 实现交错效果 | 使用带有 staggerChildren 的 variants |
AnimatePresence 中缺少 key | 为子元素添加唯一的 key |
| 在服务端组件中添加动画 | 添加 "use client" 或使用 motion/react-client |
| 忽略减少动态效果的设置 | 始终检查 useReducedMotion() |
挂载时使用 animate,但未设置 initial | 设置 initial 以定义起始状态 |
示例
滚动进度条
const { scrollYProgress } = useScroll()
return (
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
style={{ scaleX: scrollYProgress }}
/>
)
动画计数器
const count = useMotionValue(0)
const rounded = useTransform(count, (v) => Math.round(v))
useEffect(() => {
const controls = animate(count, target, { duration: 2 })
return controls.stop
}, [target])
return <motion.span>{rounded}</motion.span>
页面过渡(Next.js App Router)
// template.tsx
"use client"
import { motion } from "framer-motion"
export default function Template({ children }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}
动画渐变文本(闪烁效果)
const progress = useMotionValue(0)
useAnimationFrame((time) => {
const duration = 8000
const fullCycle = duration * 2
const cycleTime = (time % fullCycle)
if (cycleTime < duration) {
progress.set((cycleTime / duration) * 100)
} else {
progress.set(100 - ((cycleTime - duration) / duration) * 100)
}
})
const backgroundPosition = useTransform(progress, (p) => `${p}% 50%`)
return (
<motion.span
className="bg-clip-text text-transparent"
style={{
backgroundImage: "linear-gradient(to right, #5227FF, #a78bfa, #c084fc, #5227FF)",
backgroundSize: "300% 100%",
backgroundPosition,
}}
>
{text}
</motion.span>
)