Framer Motion & GSAP 实现酷炫动画
- Framer Motion v12.22.0
- GSAP v3.13.0
GSAP 一个老牌的动画库,兼容性好,方便易用,功能强大,最近开源了更多插件,目前依旧是最好的选择,更喜欢它的文档和使用方式。
越来越多的开源库,开源项目使用 Framer Motion 来实现动画效果,Framer Motion 变得越来越重要。
动画能让用户有更好的体验,而好看酷炫的动画不仅跟设计师有关,也跟我们前端息息相关,普通的设计师往往没有太多动画的灵感,或者想出的方案我们不太好实现,不如自己提需求,依靠自己的经验去 push 做出合理好看的动画。
imber Animation
imber Animation 是动画 Demo 展示,里面有各种动画效果可以参考,文章下面的所有动画效果,都可以在 Github 源码仓库 找到,动画的右上角有查看源码功能,网站部分动画用 GSAP 和 Framer Motion 实现过。

文字拆分动画(Split)
GSAP 开源后的 SplitText 插件,实现文字拆分动画非常方便,注意在完成时 revert,减少后续性能开销,framer motion 的 SplitText 没有开源,需要钱使用它的 Motion+ ,只能自己实现会麻烦很多。
标题,logo 等地方可以使用这个动画

组件代码
tsx
'use client'
import { gsap } from 'gsap'
import { useGSAP } from '@gsap/react'
import { SplitText } from 'gsap/SplitText'
import { useRef } from 'react'
gsap.registerPlugin(SplitText)
const SplitTextGsap = ({ text, className }: { text: string; className?: string }) => {
const gsapTextRef = useRef<HTMLDivElement>(null)
useGSAP(() => {
if (gsapTextRef.current) {
let split = new SplitText(gsapTextRef.current, { type: 'chars,words' })
gsap.from(split.chars, {
autoAlpha: 0,
yPercent: 'random([-100,100])',
rotation: 'random([-30,30])',
ease: 'back.out',
// repeat: -1,
// yoyo: true,
stagger: {
amount: 0.5,
from: 'random'
},
onComplete: () => {
split.revert()
}
})
}
})
return (
<div className={`${className}`} ref={gsapTextRef}>
{text}
</div>
)
}
export default SplitTextGsap
文字高斯模糊动画(Blur)
博客首页的文字高斯模糊动画,也是使用 GSAP 的 SplitText 插件实现,核心逻辑和上面差不多,主要使用 blur 去做每个字的效果。
标题,logo 等地方可以使用这个动画

组件代码
tsx
'use client'
import React, { useRef } from 'react'
import { gsap } from 'gsap'
import { useGSAP } from '@gsap/react'
import { SplitText } from 'gsap/SplitText'
// 注册SplitText插件
gsap.registerPlugin(SplitText)
const BlurTextGsap = ({
text = '',
children,
delay = 50, // 默认更快的delay,像React Spring版本
className = '',
animateBy = 'words',
direction = 'top',
onAnimationComplete,
ease = 'none' // 使用linear ease让动画更匀速
}) => {
const containerRef = useRef<HTMLParagraphElement>(null)
const splitRef = useRef<SplitText | null>(null)
useGSAP(() => {
if (!containerRef.current) return
// 使用SplitText拆分文字
splitRef.current = new SplitText(containerRef.current, {
type: animateBy
})
const elements = splitRef.current[animateBy] as HTMLElement[]
// 设置初始状态 - 更接近React Spring版本
gsap.set(elements, {
filter: 'blur(10px)',
opacity: 0,
y: 0, // 移除y轴移动,更专注于模糊效果
willChange: 'transform, filter, opacity'
})
// 创建时间线动画
const tl = gsap.timeline({
onComplete: onAnimationComplete
})
// 第一阶段:从完全模糊到半模糊
tl.to(elements, {
filter: 'blur(5px)',
opacity: 0.5,
y: 0,
stagger: {
amount: (elements.length * delay) / 1000, // 使用amount而不是each来控制总时间
ease: 'none' // stagger也使用linear
},
ease: ease
}).to(elements, {
filter: 'blur(0px)',
opacity: 1,
stagger: {
amount: (elements.length * delay) / 1000,
ease: 'none'
},
ease: ease
})
return () => {
// 清理SplitText实例
splitRef.current?.revert()
}
}, [text, children, delay, animateBy, direction, onAnimationComplete, ease])
return (
<p ref={containerRef} className={`${className}`}>
{children || text}
</p>
)
}
export default BlurTextGsap
数字递增动画(Add)
GSAP 的 textContent 和 roundProps,可以很方便实现这个效果,Framer Motion 同样简单。
有数字的时候可以考虑这个动画。

组件代码 GSAP
tsx
'use client'
import React, { useRef } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
const AddTextGsap = ({ from, to, className }: { from: number; to: number; className: string }) => {
const ref = useRef<HTMLHeadingElement>(null)
useGSAP(() => {
gsap.to(ref.current, {
textContent: to,
duration: 1,
ease: 'power2.inOut',
roundProps: 'textContent'
})
})
return (
<h1 ref={ref} className={className}>
{from}
</h1>
)
}
export default AddTextGsap
组件代码 Framer Motion
tsx
'use client'
import { motion, useMotionValue, useTransform, animate } from 'framer-motion'
import { useEffect } from 'react'
const AddTextFramer = ({ from, to, className }: { from: number; to: number; className: string }) => {
const count = useMotionValue(from)
const rounded = useTransform(count, (latest) => Math.round(latest))
useEffect(() => {
const animation = animate(count, to, {
duration: 1,
ease: 'easeInOut'
})
return animation.stop
}, [count, to])
return (
<motion.h1 transition={{ duration: 1, ease: 'easeInOut' }} className={className}>
<motion.span>{rounded}</motion.span>
</motion.h1>
)
}
export default AddTextFramer
错开动画(Stagger)
gsap 使用 stagger 错开动画,framer motion 使用 transition 的 delay 错开动画。
列表,网格布局 等地方可以使用这个动画。

同理,这种小方块的布局也是列表形式的,也比较适合 stagger 动画。

核心逻辑
tsx
// gsap
gsap.from('.stagger-item', {
opacity: 0,
y: 15,
stagger: 0.1,
duration: 0.4
})
// framer motion
{
postsConfig.map((post, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.1 }}
></motion.div>
))
}
时间轴动画(Timeline)
做一些简单有执行顺序的动画,比如入场动画片,GSAP 有 timeline 时间线,让动画依次执行,而 Framer Motion 可以设置 delay。
使用 GSAP 的时候这些控制时间轴的 API 比较重要,首先默认是 0.5s
-
+=1 - 时间轴末尾后 1 秒(产生间隙)
-
-=1 - 时间轴结束前 1 秒(重叠)
-
myLabel+=2 - 标签myLabel后 2 秒
-
<+=3 - 上一个动画开始后 3 秒
-
<3 - 与<+=3 相同(<或>后面隐含+=)
-
> -0.5 - 上一个动画结束前 0.5 秒。这就像说上一个动画的结束加上 -0.5
适合入场动画,各种有顺序动画,甚至左右布局的模块,可以依次从透明到显示。

核心逻辑
tsx
import { motion } from 'framer-motion'
import Image from 'next/image'
const Framer = () => {
return (
<section className="min-h-screen overflow-x-hidden bg-black pt-52 text-center text-white">
<div className="absolute top-32 left-1/2 z-10 -translate-x-1/2">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
className="z-10 mx-auto mb-10"
>
<Image src="https://fms.res.meizu.com/dms/2023/03/29/221febae-e651-410a-903f-29e0bd051ac7.png" />
</motion.div>
<MotionImage
src="https://fms.res.meizu.com/dms/2023/03/29/399cc024-ff70-4cf2-8011-5b86e6313b1f.png"
alt="framer motion"
width={548}
height={80}
className="title2"
containerClassName="z-10"
initial={{ scale: 5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.8 }}
/>
</div>
<motion.div
initial={{ x: '10%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 1, delay: 1.3 }}
>
<Image src="https://fms.res.meizu.com/dms/2023/05/24/a8ee0203-5636-4b61-b6bf-7e66b5f671b5.jpg" />
</motion.div>
</section>
)
}
export default Framer
React 退出动画(Exit)
在 React 中,组件删除,通常被立即删除,而没有动画,但可以使用 Framer Motion 和 GSAP 来实现退出动画,比如做打开关闭弹层,这类动画用 Framer Motion 更方便,因为它封装了一个 AnimatePresence 组件,而 GSAP 需要用 requestAnimationFrame 来判断下一帧的时候等 DOM 渲染好了,再执行动画。
适合弹层,有切换关闭效果的动画。

核心逻辑
tsx
import { AnimatePresence, motion } from 'framer-motion'
;<AnimatePresence>
{true && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<motion.div
initial={{
opacity: 0,
scale: 0.95,
x: '-50%',
y: '-48%'
}}
animate={{
opacity: 1,
scale: 1,
x: '-50%',
y: '-50%'
}}
exit={{
opacity: 0,
scale: 0.95,
x: '-50%',
y: '-48%'
}}
transition={{ duration: 0.2, ease: 'easeOut' }}
></motion.div>
</motion.div>
)}
</AnimatePresence>
布局动画(Layout)
这个动画经常使用到,比如博客的主题切换按钮,正常的 flex 布局的切换是没有过渡效果的,使用 Framer Motion 提供的 layout 参数,可以实现布局切换的过渡效果,如果是 GSAP 的话,需要用 Flip 插件实现。
适合布局切换,比如 flex 的 flex-start 和 flex-end 切换。


核心逻辑
tsx
// 核心是这里的 justify-end justify-start 切换
<button className={` ${isDark ? 'justify-end bg-gray-700 shadow-inner' : 'justify-start bg-blue-200 shadow-inner'} }`}>
<motion.div
// 自动处理布局变化
layout
transition={{
// 指定动画类型为弹簧动画
type: 'spring',
// 设置动画的视觉感知持续时间为 0.2 秒
visualDuration: 0.2,
// 控制弹簧的弹跳强度
bounce: 0.2
}}
// 确保这个元素不会阻挡点击事件
style={{ pointerEvents: 'none' }}
>
{isDark ? <Moon className="h-3 w-3" /> : <Sun className="h-3 w-3" />}
</motion.div>
</button>
布局动画 - Tab切换(Tab)
不同元素之间的切换状态,这里使用官方的 Demo,正常这个下划线是没有动画效果,加上 layoutId 和 id 后,可以实现动画过渡效果。
适合布局切换,比如 tab 切换。

核心逻辑
tsx
{
item === selectedTab ? (
<motion.div
className="absolute right-0 -bottom-0.5 left-0 h-0.5 bg-[#ff4132]"
layoutId="underline"
id="underline"
/>
) : null
}
基础滚动动画(Scroll)
使用 GSAP 的时候,这些 scrollTrigger 的 API 配置很关键,对于 Framer Motion 来说,用 whileInView 来做这种动画,不能用 useScroll 和 useTransform 因为无法删掉它们的链接到滚动条效果。
tsx
useGSAP(() => {
const t1 = gsap.timeline({
scrollTrigger: {
markers: true, // 显示标记,方便开发时调试动画触发时机
trigger: '.box', // 触发动画的元素
start: 'top center', // 元素相对自身的位置 和 视口的位置,两者重合即触发动画开始
end: 'bottom 40%', // 元素相对自身的位置 和 视口的位置,两者重合即触发动画结束
toggleActions: 'play none none reverse', // 动画触发时机,常用的值是 play none reverse,对应 onEnter, onLeave, onEnterBack, and onLeaveBack
onEnter: () => {
console.log('onEnter')
},
onLeave: () => {
console.log('onLeave')
},
onEnterBack: () => {
console.log('onEnterBack')
},
onLeaveBack: () => {
console.log('onLeaveBack')
},
onComplete: () => {
console.log('onComplete')
}
// scrub: true // 动画与滚动条绑定,也就是不会直接执行完动画,会跟着滚动条执行
}
})
t1.from('.box', {
y: 50,
duration: 0.5, // 默认 0.5s
autoAlpha: 0, // 透明度
ease: 'power1.inOut' // 默认 power1.inOut
})
})
注意 scrollTrigger 是有顺序的,并且对于图片一定要给个父级和父亲给宽高,否则会计算出问题
GSAP 是基于 ScrollTrigger 插件来滚动动画,Framer Motion 是基于 useScroll 和useTransform 钩子,两者都可以实现滚动动画。
适合滚动动画,比如滚动到某个位置时,出现某个元素。

核心逻辑
tsx
useGSAP(() => {
// 通用的 scrollTrigger 配置
const commonScrollTriggerConfig = {
markers: true, // 显示标记,方便开发时调试动画触发时机
start: 'center 90%', // 元素相对自身的位置 和 视口的位置,两者重合即触发动画开始
toggleActions: 'play none none reverse', // 动画触发时机
onEnter: () => console.log('onEnter'),
onLeave: () => console.log('onLeave'),
onEnterBack: () => console.log('onEnterBack'),
onLeaveBack: () => console.log('onLeaveBack')
}
// 通用的动画配置
const commonAnimationConfig = {
duration: 0.5,
autoAlpha: 0,
ease: 'power1.inOut'
}
// 为每一对 box 创建动画(0-1, 2-3)
for (let i = 0; i < 4; i += 2) {
const timeline = gsap.timeline({
scrollTrigger: {
...commonScrollTriggerConfig,
trigger: `.box${i}` // 以每对的第一个元素作为触发器
}
})
// 第一个 box 从左进入
timeline.from(`.box${i}`, {
...commonAnimationConfig,
x: '-10%'
})
// 第二个 box 从右进入,与第一个动画重叠
timeline.from(
`.box${i + 1}`,
{
...commonAnimationConfig,
x: '10%'
},
'-=0.5'
)
}
})
还可以设置文字的滚动动画,比如边滚动边高亮。

核心逻辑
tsx
gsap
.timeline({
scrollTrigger: {
trigger: ref.current,
start: 'top 90%',
end: '+=1000',
scrub: 1, // 慢 1s 跟上滚动条
toggleActions: 'play none none reverse'
}
})
.to('.section2_line', {
stagger: 0.1,
y: -40,
keyframes: {
'0%': { color: '#4c4c4c' },
'25%': { color: '#4c4c4c' },
'50%': { color: '#ffffff' },
'75%': { color: '#4c4c4c' },
'100%': { color: '#4c4c4c' }
}
})
滚动动画 + scrub 效果(Scrub)
gsap 的 scrub 效果,也就是动画与滚动条绑定,也就是不会直接执行完动画,会跟着滚动条执行,对于 Framer Motion 来说,用 useScroll 和 useTransform 是默认就有这个效果,而且默认会有 reverse。

核心逻辑
tsx
useGSAP(() => {
const t1 = gsap.timeline({
scrollTrigger: {
trigger: containerRef.current,
markers: true,
start: 'center bottom',
end: 'top top',
scrub: 1,
toggleActions: 'play none none reverse'
}
})
t1.to(containerRef.current, {
right: '0'
})
})
滚动动画 + pin 效果(Pin)
gsap 的 pin 效果,也就是滚动到某个位置时,元素会固定在页面不动,对于 Framer Motion 来说,比较难做和难理解这个效果,方案可以用 paddingBottom 撑开或者给个很高的高度,暂时不做深入研究,还是 GSAP 方便。

核心逻辑
tsx
useGSAP(() => {
const t1 = gsap
.timeline({
scrollTrigger: {
trigger: '.section-block',
start: 'center center',
end: '+=2000',
toggleActions: 'play none reverse none',
pin: true,
scrub: 1,
markers: true
}
})
.addLabel('spin')
t1.to(
'.section-video ',
{
width: '50px',
height: '50px',
left: 23,
top: 265
},
'spin'
)
t1.to(
'.section-img',
{
autoAlpha: 1
},
'spin'
)
// 第一组气泡从右侧依次进入
t1.to(['.bubble1', '.bubble2', '.bubble3'], {
x: '0%',
autoAlpha: 1,
stagger: 0.5 // 每个元素间隔0.5秒
})
// 第二组气泡在第一组完成后从左侧依次进入
t1.to(
['.bubble4', '.bubble5', '.bubble6'],
{
x: '0%',
autoAlpha: 1,
stagger: 0.5, // 每个元素间隔0.5秒
duration: 0.6
},
'+=0.3'
) // 在上一个动画完成后延迟0.3秒开始
})
滚动动画 + 垂直叠层效果(Vertical)
也就是固定对应的屏,之前写的项目用这个也比较好看,如 领克Z10 starbuff,此外除了下面这种,还有种 snap 的效果在一些网页里也挺常见

tsx
useGSAP(() => {
ScrollTrigger.create({
trigger: '.box1',
start: 'top top',
end: `+=${window.innerHeight}`,
pin: '.box1',
markers: true,
pinSpacing: false
// anticipatePin: 1,
})
ScrollTrigger.create({
trigger: '.box2',
start: 'top top',
end: `+=${window.innerHeight}`,
pin: '.box2',
markers: true,
pinSpacing: false
// anticipatePin: 1
})
})
滚动动画 + 水平叠层效果(Horizontal)
这里麻烦的地方是一个位置计算和 FOUC 问题,暂时用 invisible 优化

核心逻辑
tsx
useGSAP(() => {
const t1 = gsap.timeline({
scrollTrigger: {
trigger: '#container',
start: 'top top',
end: `+=${window.innerHeight}`,
pin: true,
markers: true,
scrub: 1
}
})
t1.set('.box1', {
visibility: 'visible'
})
t1.from('.box1', {
height: window.innerHeight,
width: window.innerWidth,
transform: `translateX(calc(240px + 50vw))` // 按情况计算
})
t1.to('#other-container', {
x: 0
})
t1.to('#inner-container', {
right: 0
})
})
滚动动画 + 视差效果(Parallax)
-
data-speed 表示比正常滚动慢多少,对于图片来说,可以在父元素上 overflow hidden,然后内层图片添加 data-speed
-
data-lag 表示延迟多少秒开始滚动,需要一定时间赶上

也就是产生不同速率滚动效果,比如背景图片滚动速度不一样,或者文字滚动速度不一样。
核心逻辑
tsx
useGSAP(() => {
ScrollSmoother.create({
smooth: 1, //需要多长时间(以秒为单位)才能"赶上"原始滚动位置
effects: true, //查找元素上的数据速度和数据滞后属性
wrapper: '#smooth-wrapper',
content: '#smooth-content'
})
})