Motion for React 实战模式手册:从入场动画到滚动驱动的 9 种典型用法
前言
在一个追求「克制但精致」动效体验的博客项目中,Motion(原 Framer Motion)几乎是绑定在每个交互细节上的基础设施——从首页标题的逐字模糊入场,到导航栏 hover 时的共享高亮滑块,再到阅读进度环的弹簧物理,30 多个文件里散落着各种用法。
本文把这些实战场景收拢为 9 种可复用的动效模式,每种模式都从「为什么需要」出发,给出最小可运行的代码骨架,然后展示项目中的真实实现。如果你正在用 Motion 但总觉得只会 initial + animate,希望这篇手册能帮你打开更多可能性。
关于命名:项目依赖的包名是
motion,导入路径统一为motion/react。文中的「Motion」即指这一生态。
一、入场动画:initial + animate + transition
这是 Motion 最基础也最高频的模式。核心思路:声明元素的「初始状态」和「目标状态」,Motion 自动补间。
1.1 最小示例
1import { motion } from "motion/react";2
3function FadeInCard() {4 return (5 <motion.div6 initial={{ opacity: 0, y: 20 }}7 animate={{ opacity: 1, y: 0 }}8 transition={{ duration: 0.5, ease: "easeOut" }}9 >10 Hello Motion11 </motion.div>12 );13}1.2 项目实战:文章详情页的分段入场
在博客的文章详情页里,标题、元信息、正文、作者卡片、评论区分别设置不同的 delay,形成由上至下的「瀑布式」入场节奏:
1{/* 返回按钮 - 最先出现 */}2<motion.div3 initial={{ opacity: 0, x: -20 }}4 animate={{ opacity: 1, x: 0 }}5 transition={{ duration: 0.4, ease: "easeOut" }}6>7 <Button variant="ghost">返回列表</Button>8</motion.div>9
10{/* 正文 - 稍后出现 */}11<motion.div12 initial={{ opacity: 0, y: 25 }}13 animate={{ opacity: 1, y: 0 }}14 transition={{ duration: 1.2, delay: 0.7, type: "spring" }}15>16 <MarkdownRenderer content={post.content} />17</motion.div>18
19{/* 作者卡片 - 最后出现 */}20<motion.div21 initial={{ opacity: 0, y: 25, scale: 0.98 }}22 animate={{ opacity: 1, y: 0, scale: 1 }}23 transition={{ duration: 0.5, delay: 1.0, ease: [0.22, 1, 0.36, 1] }}24>25 {/* 作者信息... */}26</motion.div>要点:
type: "spring"让运动更自然,不需要手动指定duration(但也可以同时指定)- 自定义贝塞尔曲线
ease: [0.22, 1, 0.36, 1]可以实现先快后慢的「出场」手感 - 多区块之间通过递增的
delay编排节奏,比一股脑全部淡入更有层次
二、视口触发:useInView 与 whileInView
很多动画不应在页面加载时就触发,而是当用户滚动到可见区域时才开始。Motion 提供了两种方式。
2.1 useInView Hook(命令式)
适合需要精确控制时机的场景——比如进入视口后再延迟一段时间才触发。
1import { motion, useInView } from "motion/react";2
3const ref = useRef<SVGSVGElement>(null);4const isInView = useInView(ref, { once: true, amount: 0.5 });5const [isVisible, setIsVisible] = useState(false);6
7useEffect(() => {8 if (isInView) {9 const timer = setTimeout(() => setIsVisible(true), startDelay * 1000);10 return () => clearTimeout(timer);11 }12}, [isInView, startDelay]);这段代码来自项目中的签名描边动画组件:元素进入视口 50% 后(amount: 0.5),再等一个自定义延迟才开始 SVG 路径绘制。once: true 确保动画只播放一次。
2.2 whileInView(声明式)
更简洁,适合「进视口就动」的场景:
选择建议:
| 场景 | 推荐方式 |
|---|---|
| 简单的入场淡入 | whileInView |
| 需要额外延迟 / 与其他状态联动 | useInView |
| 需要在非 Motion 元素上检测视口 | useInView |
三、编排多元素:variants + staggerChildren
当一组元素需要「依次出场」时,variants 比手动写 N 个 delay 优雅得多。
3.1 核心概念
1const stagger = {2 container: {3 animate: {4 transition: { staggerChildren: 0.05 },5 },6 },7 item: {8 initial: { opacity: 0, x: -32 },9 animate: { opacity: 1, x: 0 },10 transition: { duration: 0.6, type: "spring", bounce: 0 },11 },12};父容器声明 staggerChildren,子元素只需挂 variants 即可自动获得错开的延迟。
3.2 项目实战:文章元信息 + 标签行
1{/* 父容器驱动 stagger */}2<motion.div3 className="flex flex-wrap items-center gap-4"4 initial="initial"5 animate="animate"6 variants={stagger.container}7>8 {[9 { icon: UserIcon, text: post.author.name },10 { icon: CalendarIcon, text: post.date },11 { icon: ClockIcon, text: post.readTime },12 ].map(({ icon: Icon, text }, index) => (13 <motion.div14 key={index}15 variants={stagger.item}16 transition={{17 duration: 0.6,18 delay: 1.4 + index * 0.05,19 type: "spring",20 bounce: 0,21 }}22 >23 <Icon className="h-4 w-4" />24 <span>{text}</span>25 </motion.div>26 ))}27</motion.div>技巧:子元素的 transition.delay 可以叠加一个固定偏移量(如 1.4),让整组动画在页面入场动画的后半段才开始,形成更自然的衔接。
四、退出过渡:AnimatePresence
React 的条件渲染({show && <div>...</div>})在卸载时是瞬间消失的。AnimatePresence 允许子组件在被移除前播放退出动画。
4.1 图标切换:mode="popLayout"
项目中的「双状态切换按钮」用于 主题切换、菜单开关 等场景:
1<AnimatePresence mode="popLayout" initial={false}>2 {active ? (3 <motion.span4 key="dual-toggle-active"5 initial={{ opacity: 0, rotate: -90 }}6 animate={{ opacity: 1, rotate: 0 }}7 exit={{ opacity: 0, rotate: 90 }}8 transition={{ duration: 0.2 }}9 >10 {activeIcon}11 </motion.span>12 ) : (13 <motion.span14 key="dual-toggle-inactive"15 initial={{ opacity: 0, rotate: -90 }}16 animate={{ opacity: 1, rotate: 0 }}17 exit={{ opacity: 0, rotate: 90 }}18 transition={{ duration: 0.2 }}19 >20 {inactiveIcon}21 </motion.span>22 )}23</AnimatePresence>关键细节:
mode="popLayout":退出元素在退出动画期间会脱离文档流(position: absolute),新元素可以立即占位,避免「两个元素同时占空间」的跳动mode="wait":新元素等退出动画完成后才入场,适合内容切换initial={false}:组件首次挂载时不播放入场动画
4.2 移动端导航展开/收起
1<AnimatePresence mode="wait">2 {mobileMenuOpen && (3 <motion.div4 key="mobile-menu"5 initial={{ height: 0, opacity: 0 }}6 animate={{7 height: "auto",8 opacity: 1,9 transition: {10 height: { type: "spring", stiffness: 300, damping: 30 },11 opacity: { duration: 0.2 },12 },13 }}14 exit={{15 height: 0,16 opacity: 0,17 transition: {18 height: { type: "spring", stiffness: 300, damping: 30 },19 opacity: { duration: 0.15 },20 },21 }}22 >23 {/* 菜单内容 */}24 </motion.div>25 )}26</AnimatePresence>注意 height: "auto" ——Motion 可以在确定值和 auto 之间做动画,这在原生 CSS 中是非常难实现的。
五、共享布局动画:layoutId 与 LayoutGroup
layoutId 是 Motion 最「魔法」的能力之一:两个不同的 motion.* 元素只要共享同一个 layoutId,Motion 就会自动在它们之间生成平滑的位置 + 尺寸过渡。
5.1 导航栏 Hover 指示器
项目中的顶部导航栏,鼠标在菜单项之间移动时,高亮背景会平滑地「滑过去」:
1{navItems.map((item) => (2 <Link3 key={item.href}4 onMouseEnter={() => setHoveredItem(item.href)}5 onMouseLeave={() => setHoveredItem(null)}6 >7 <AnimatePresence>8 {hoveredItem === item.href && (9 <motion.span10 layoutId="nav-hover"11 className="absolute inset-0 bg-accent/50 rounded-md -z-10"12 initial={{ opacity: 0 }}13 animate={{ opacity: 1 }}14 exit={{ opacity: 0 }}15 transition={{ type: "spring", duration: 0.68, bounce: 0 }}16 />17 )}18 </AnimatePresence>19 {item.title}20 </Link>21))}原理:同一时刻只有一个 hoveredItem 匹配,所以只有一个 motion.span 被渲染。但由于它们共享 layoutId="nav-hover",Motion 会将前一个元素的位置「过渡到」新元素的位置,视觉上就像一个指示器在滑动。
5.2 LayoutGroup 的作用
当多个独立组件实例各自使用 layoutId 时,可能会产生命名冲突(比如两个列表都有 layoutId="hover")。用 LayoutGroup 包裹可以创建隔离的命名空间:
六、滚动驱动动画:useScroll + useTransform
这是 Motion 最强大的模式之一——将滚动位置直接映射为动画属性,实现「滚动即动画」。
6.1 底部高光线延展效果
项目中页面底部有一条柔金色高光线,随着 footer 进入视口从中间向两侧延展:
1const containerRef = useRef<HTMLDivElement>(null);2
3const { scrollYProgress } = useScroll({4 target: containerRef,5 offset: ["start end", "start 0.7"],6});7
8const smoothProgress = useSpring(scrollYProgress, {9 damping: 50, stiffness: 400, restDelta: 0.001,10});11
12const scaleX = useTransform(smoothProgress, [0, 1], [0, 1]);13const opacity = useTransform(smoothProgress, [0, 0.3, 1], [0, 0.5, 1]);14
15return (16 <motion.div17 style={{ scaleX, opacity }}18 className="h-px w-[80%]"19 />20);数据流图解:
要点:
useScroll({ target, offset })可以追踪特定元素相对于视口的滚动进度offset: ["start end", "start 0.7"]表示「元素顶部碰到视口底部」时 progress=0,「元素顶部到达视口 70% 处」时 progress=1- 中间插入
useSpring可以让进度变化更平滑,避免生硬的线性跟随
6.2 阅读进度环
文章详情页的「灵动岛目录」组件中,用 SVG 圆环显示阅读进度:
1const { scrollYProgress } = useScroll();2
3const smoothProgress = useSpring(scrollYProgress, {4 damping: 20, stiffness: 100,5});6
7const circumference = 62.83; // 2πr8const strokeDashoffset = useTransform(9 smoothProgress,10 [0, 1],11 [circumference, 0]12);将页面整体滚动进度映射为 SVG 的 strokeDashoffset,进度从 0 到 100% 时,圆环从空到满。弹簧参数 damping: 20 比高光线的 50 更低,所以进度环会有轻微的「弹性回弹」感,更加灵动。
七、弹簧物理:useSpring 与 useMotionValue
useSpring 不仅可以搭配 useScroll,还可以单独驱动任何数值动画。
7.1 打字机宽度弹簧
项目中的 Typewriter 组件需要在文字增删时平滑调整容器宽度:
1const smoothWidth = useSpring(0, {2 stiffness: 300,3 damping: 30,4});5
6useEffect(() => {7 if (!displayText) {8 smoothWidth.set(0);9 } else if (measureRef.current) {10 smoothWidth.set(measureRef.current.scrollWidth);11 }12}, [displayText, smoothWidth]);13
14return (15 <motion.span style={{ width: smoothWidth }}>16 {currentPhrase}17 </motion.span>18);核心思路:
- 用一个隐藏的
<span ref={measureRef}>测量当前文字的真实宽度 - 通过
smoothWidth.set(width)更新弹簧的目标值 motion.span的style={{ width: smoothWidth }}自动跟随弹簧值平滑过渡
这比用 CSS transition: width 好在哪里?弹簧物理会产生自然的「过冲-回弹」效果,而不是匀速或贝塞尔缓动。
7.2 useMotionValue 的适用场景
useMotionValue 创建一个不触发 React 重渲染的响应式值,适合高频更新的场景:
如果用 useState 替代,每帧都会触发 React 重渲染,性能会急剧下降。useMotionValue + style 绑定走的是 Motion 内部的直接 DOM 更新通道,完全绕过了 React 调和。
八、SVG 路径描边动画:pathLength
Motion 对 SVG 有一等支持,motion.path 可以直接动画化 pathLength 属性,实现手写签名、图标描边等效果。
项目实战:签名动画
1<motion.svg ref={ref} viewBox="0 0 1080.5 419">2 {strokes.map((stroke, index) => (3 <motion.path4 key={index}5 d={stroke.d}6 fill="none"7 stroke={strokeColor}8 strokeLinecap="round"9 initial={{ pathLength: 0, opacity: 0 }}10 animate={11 isVisible12 ? { pathLength: 1, opacity: 1 }13 : { pathLength: 0, opacity: 0 }14 }15 transition={{16 pathLength: {17 duration: timing.duration,18 delay: timing.absoluteDelay,19 ease: "easeInOut",20 },21 opacity: {22 duration: 0.01,23 delay: timing.absoluteDelay,24 },25 }}26 />27 ))}28</motion.svg>实现细节:
- 签名被拆分为多个笔画(
strokes),每笔独立配置duration和delay - 通过预计算每笔的「绝对开始时间」(
absoluteDelay),精确编排多笔画的先后顺序 pathLength: 0 → 1控制描边从无到有,opacity几乎瞬间切换(duration: 0.01),避免笔画在描绘前可见
九、逐帧动画:useAnimationFrame + 滚动速度
当需要每帧精确控制动画时(如无限滚动跑马灯),useAnimationFrame 是最底层的钩子。
项目实战:滚动速度驱动的跑马灯
这段代码构建了一个「滚动速度 → 弹簧平滑 → 速度因子」的管线。然后在 useAnimationFrame 中每帧更新位移:
1const baseX = useMotionValue(0);2
3useAnimationFrame((_, delta) => {4 if (!isInViewRef.current || !isPageVisibleRef.current) return;5 const dt = delta / 1000;6 const vf = velocityFactor.get();7 const speedMultiplier = 1 + Math.min(5, Math.abs(vf));8 const moveBy = direction * pixelsPerSecond * speedMultiplier * dt;9 baseX.set(baseX.get() + moveBy);10});11
12return (13 <motion.div14 className="inline-flex will-change-transform"15 style={{ x }}16 >17 {/* 重复多份内容实现无缝衔接 */}18 </motion.div>19);性能守则:
- 检查
isInViewRef:元素不可见时跳过计算 - 检查
isPageVisibleRef:页面隐藏时暂停(visibilitychange) - 检查
prefers-reduced-motion:尊重系统无障碍设置 - 使用
useMotionValue而非useState:避免每帧触发 React 重渲染 - 添加
will-change-transform+transform-gpu:提示浏览器启用 GPU 合成
模式速查表
| 模式 | 核心 API | 典型场景 | 性能考量 |
|---|---|---|---|
| 入场动画 | initial + animate | 页面/卡片首次出现 | 低——一次性 |
| 视口触发 | useInView / whileInView | 长页面中的懒动画 | 低——once: true |
| 编排 | variants + staggerChildren | 列表、标签行、元信息 | 低——声明式 |
| 退出过渡 | AnimatePresence | 切换、折叠、模态框 | 中——注意 mode 选择 |
| 共享布局 | layoutId + LayoutGroup | 导航指示器、Tab 切换 | 中——layout 计算 |
| 滚动驱动 | useScroll + useTransform | 视差、进度条、延展 | 中——配合 useSpring |
| 弹簧物理 | useSpring + useMotionValue | 宽度、数值、阻尼跟随 | 低——绕过 React |
| SVG 描边 | motion.path + pathLength | 签名、图标、加载指示 | 低——GPU 友好 |
| 逐帧动画 | useAnimationFrame | 跑马灯、粒子、物理模拟 | 高——需手动优化 |
写在最后
Motion 的 API 设计哲学是声明式优先、逐步下沉:
- 大多数场景用
initial+animate+variants就能覆盖 - 需要滚动联动时引入
useScroll+useTransform - 需要极致性能时用
useMotionValue+useAnimationFrame绕过 React 渲染
克制是最好的动效策略。项目中遵循的时长参考:
- 快速交互(hover / tap):
0.15s - 0.3s - 常规过渡(入场 / 切换):
0.4s - 0.6s - 入场动画(页面级):
0.6s - 1s - 环境动画(呼吸 / 光晕):
8s - 10s
动效的目的是引导注意力和传达状态变化,而不是「秀技术」。当用户注意到动画本身时,往往意味着它太过了。好的动效应该像呼吸一样自然——存在,但不打扰。