mcpField
F
framer-motion

framer-motion

github.com/schoepplake设计与 UI5156 次安装32 周前更新
代码仓库

关于 framer-motion

面向 React 和 Next.js 开发者的 Motion(Framer Motion)动画实现指南,介绍库的常用模式、性能注意事项及无障碍实践。

主要功能

  1. 01滚动触发与交错动画
  2. 02布局变化和退出动画
  3. 03手势、拖动与弹簧动画
  4. 04MotionValues 帧更新
  5. 05减少动态效果偏好支持

适用场景

  1. 01为页面元素添加滚动入场效果
  2. 02实现条件渲染内容的退出动画
  3. 03构建可拖动或手势交互组件
  4. 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(),然后完全跳过动画,或将动画简化为仅改变不透明度(不移动)。

性能规则

  1. 切勿为 width、height、top、left 播放动画——这些属性会触发布局重新计算。请改用 transform 属性(x、y、scale、rotate)。

  2. 使用 MotionValues 进行逐帧更新——useState 会导致每帧都重新渲染。MotionValues 会直接更新 DOM。

  3. will-change: transform 会由 motion 组件自动添加——不要手动添加。

  4. layout 动画开销较大——请有选择地使用,不要用于每个元素。

  5. 避免为 box-shadow 播放动画——改用 filter: drop-shadow(),或为伪元素阴影的不透明度播放动画。

  6. 优先使用 opacity 和 transform——它们由 GPU 合成,并在单独的线程上运行。

  7. **对于交互元素(悬停、轻触、拖动),弹簧过渡比 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>
)

评论

支持 Markdown