Framer Motion & GSAP 实现酷炫动画

Framer Motion & GSAP 实现酷炫动画

GSAP 一个老牌的动画库,兼容性好,方便易用,功能强大,最近开源了更多插件,目前依旧是最好的选择,更喜欢它的文档和使用方式。

越来越多的开源库,开源项目使用 Framer Motion 来实现动画效果,Framer Motion 变得越来越重要。

动画能让用户有更好的体验,而好看酷炫的动画不仅跟设计师有关,也跟我们前端息息相关,普通的设计师往往没有太多动画的灵感,或者想出的方案我们不太好实现,不如自己提需求,依靠自己的经验去 push 做出合理好看的动画。

imber Animation

imber Animation 是动画 Demo 展示,里面有各种动画效果可以参考,文章下面的所有动画效果,都可以在 Github 源码仓库 找到,动画的右上角有查看源码功能,网站部分动画用 GSAP 和 Framer Motion 实现过。

文字拆分动画(Split)

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

标题,logo 等地方可以使用这个动画

animation - split

组件代码

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 等地方可以使用这个动画

animation - blur

组件代码

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 同样简单。

有数字的时候可以考虑这个动画。

animation - add

组件代码 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 错开动画。

列表,网格布局 等地方可以使用这个动画。

animation - stagger

同理,这种小方块的布局也是列表形式的,也比较适合 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

适合入场动画,各种有顺序动画,甚至左右布局的模块,可以依次从透明到显示。

animation - timeline

核心逻辑

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 切换。

animation - layout

animation - flip

核心逻辑

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 切换。

animation - 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 因为无法删掉它们的链接到滚动条效果。

animation - scroll

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 钩子,两者都可以实现滚动动画。

适合滚动动画,比如滚动到某个位置时,出现某个元素。

animation - scroll

核心逻辑

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。

animation - scrub

核心逻辑

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 方便。

animation - pin

核心逻辑

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 的效果在一些网页里也挺常见

animation - vertical

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 优化

animation - horizontal

核心逻辑

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 表示延迟多少秒开始滚动,需要一定时间赶上

animation - parallax

也就是产生不同速率滚动效果,比如背景图片滚动速度不一样,或者文字滚动速度不一样。

核心逻辑

tsx 复制代码
useGSAP(() => {
  ScrollSmoother.create({
    smooth: 1, //需要多长时间(以秒为单位)才能"赶上"原始滚动位置
    effects: true, //查找元素上的数据速度和数据滞后属性
    wrapper: '#smooth-wrapper',
    content: '#smooth-content'
  })
})
相关推荐
天天扭码6 分钟前
很全面的前端面试——CSS篇(上)
前端·css·面试
EndingCoder9 分钟前
搜索算法在前端的实践
前端·算法·性能优化·状态模式·搜索算法
sunbyte13 分钟前
50天50个小项目 (Vue3 + Tailwindcss V4) ✨ | DoubleVerticalSlider(双垂直滑块)
前端·javascript·css·vue.js·vue
Favor_Yang18 分钟前
SQL Server通过存储过程实现HTML页面生成
前端·信息可视化·sqlserver·存储过程
中微子1 小时前
JavaScript事件循环机制:面试官最爱问的10个问题详解
前端
Eighteen Z1 小时前
CSS揭秘:10.平行四边形
前端·css·css3
拾光拾趣录2 小时前
虚拟DOM
前端·vue.js·dom
爱学习的茄子2 小时前
JavaScript事件循环深度解析:理解异步执行的本质
前端·javascript·面试
1024小神2 小时前
cocos游戏开发中多角色碰撞,物理反弹后改变方向的实现逻辑
前端·javascript
摆烂为不摆烂2 小时前
😁深入JS(五): 一文让你完全理解 hash 与 history 路由,手写前端路由
前端