前言

Framer Motion 是 React 生态中最流行的动画库之一。它的核心是 motion 组件——HTML/SVG 元素的动画版本,加上 AnimatePresence(处理元素卸载时的消失动画)、layout/layoutId(共享布局动画)、以及弹簧物理系统。

在 Kirameku 博客中,共有 52 个组件/页面使用了 framer-motion,覆盖了页面切换、列表入场、模态弹窗、拖拽交互、路径动画等各种场景。本文逐一拆解这些模式的实现。

1. 通用工具组件

项目里封装了两个通用动画组件,其他地方直接复用。

FadeIn:进入视口时淡入

// components/ui/FadeIn.tsx
export default function FadeIn({
  children,
  y = 20,
  delay = 0,
}: {
  children: React.ReactNode;
  y?: number;
  delay?: number;
}) {
  return (
    <motion.div
      initial={{ opacity: 0, y }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.5, delay, ease: "easeOut" }}
    >
      {children}
    </motion.div>
  );
}

设计思路:子组件向上滑入的同时淡出。y 控制滑动距离,delay 配合使用可以产生交错效果。在首页、关于页等场景大量复用。

GlassCard:毛玻璃卡片容器

// components/ui/GlassCard.tsx
<motion.div transition={{ duration: 0.3 }} {...props}>
  {children}
</motion.div>

很轻量的封装,设置默认 0.3s 过渡,通过 ...props 透传所有 framer-motion props,调用方可以按需传入 whileHover、initial、animate 等。

2. 页面级过渡

文章详情页

// app/posts/[slug]/page.tsx
<motion.div
  initial={{ opacity: 0, x: -10 }}
  animate={{ opacity: 1, x: 0 }}
  transition={{ duration: 0.3 }}
>
  ← 返回
</motion.div>

<motion.article
  initial={{ opacity: 0, y: 30 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.6, ease: "easeOut" }}
>
  {/* 文章内容 */}
</motion.article>

返回按钮从左侧滑入(0.3s),文章主体从下方滑入(0.6s)。注意用了 motion.article 直接驱动原生 article 标签。

标签切换过渡

音乐页的歌词/播放列表切换用了 AnimatePresence mode="wait":

// app/music/page.tsx
<AnimatePresence mode="wait">
  <motion.div
    key={activeTab}
    initial={{ opacity: 0, y: 10 }}
    animate={{ opacity: 1, y: 0 }}
    exit={{ opacity: 0, y: -10 }}
    transition={{ duration: 0.2 }}
  >
    {activeTab === "lyrics" ? <LyricsPanel /> : <PlaylistPanel />}
  </motion.div>
</AnimatePresence>

mode="wait" 确保旧内容完全退出后新内容才开始进入。key 变化触发 framer-motion 重新渲染。上下滑动的方向差异(y: 10 vs y: -10)让过渡更有层次感。

3. 列表交错入场

这是最常见的模式,用于文章列表、说说列表、照片墙、评论区等场景。

文章列表

// components/posts/PostCard.tsx
<motion.div
  initial={{ opacity: 0, y: 40 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.5, delay: index * 0.08, ease: "easeOut" }}
>
  {/* 卡片内容 */}
</motion.div>

delay: index * 0.08 让卡片从下往上依次出现,0.08 秒的间隔形成"一列卡片逐个浮现"的效果。配合 ease: "easeOut" 让减速更自然。

评论区

评论区同样用了交错淡入,但间隔更短(0.05s),因为评论列表通常较短:

// components/posts/PostComments.tsx
{comments.map((comment, idx) => (
  <motion.div
    key={comment.id}
    initial={{ opacity: 0, y: 20 }}
    animate={{ opacity: 1, y: 0 }}
    transition={{ duration: 0.4, delay: idx * 0.05 }}
  >
    <CommentCard comment={comment} />
  </motion.div>
))}

这个模式在 messages/page.tsx(杂谈)、moments/page.tsx(说说)中完全一致。

照片墙

照片墙的卡片不仅淡入还有旋转效果:

// components/photos/PhotoCard.tsx
<motion.div
  initial={{ opacity: 0, y: 30, rotate: rotation * 2 }}
  animate={{ opacity: 1, y: 0, rotate: rotation }}
  transition={{ duration: 0.6, delay: index * 0.08, ease: "easeOut" }}
  whileHover={{
    rotate: 0,
    scale: 1.03,
    zIndex: 10,
    transition: { type: "spring", stiffness: 300, damping: 20 },
  }}
>
  <Image />
</motion.div>

每张照片有一个随机的 rotation 值(比如 ±1.5°),进入时从 rotation * 2 的角度落下来。hover 时摆正并放大到 1.03 倍,使用弹簧物理让回弹更自然。

4. 弹簧物理与交互反馈

说说卡片悬停

说说的便利贴效果在 hover 时摆正并抬起:

// app/moments/page.tsx
<motion.div
  animate={{
    rotate: isExpanded ? 0 : rot,
    x: isExpanded ? 0 : offsetX,
  }}
  transition={{ type: "spring", stiffness: 300, damping: 25 }}
  whileHover={
    !isExpanded
      ? { rotate: 0, x: 0, y: -4, scale: 1.01 }
      : undefined
  }
>

stiffness: 300 控制回弹力度,damping: 25 控制制动速度。这两个值需要搭配调试:刚度太高像金属、太低像果冻;阻尼太大动画生硬、太小震个不停。

弹簧参数速查表

手感 stiffness damping 适用场景
硬朗干脆 400–500 30–40 弹窗出现、Tab 切换
柔和弹性 200–300 15–25 悬停效果、卡片交互
轻盈飘逸 100–150 10–15 装饰性动画

5. AnimatePresence 模态与浮层

AnimatePresence 解决的核心问题:元素移除时如何播放消失动画。没有它,卸载组件只能用 css opacity transition,或者完全不播动画。

全局 Toast 通知

// components/providers/ToastProvider.tsx
<AnimatePresence>
  {toasts.map((toast) => (
    <motion.div
      key={toast.id}
      initial={{ opacity: 0, y: 20, scale: 0.95 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      exit={{ opacity: 0, y: -20, scale: 0.95 }}
      layout
    >
      {toast.content}
    </motion.div>
  ))}
</AnimatePresence>

出现时从底部 20px 缩放入场,消失时往反方向(-20px)滑出。layout 让多条 toast 在移除时平滑调整位置。

灯箱 Lightbox

// components/photos/Lightbox.tsx
<AnimatePresence>
  {open && (
    <motion.div
      className="fixed inset-0 z-50"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      transition={{ duration: 0.25 }}
    >
      <motion.div
        key={photo.id}
        initial={{ scale: 0.85, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        exit={{ scale: 0.85, opacity: 0 }}
        transition={{ type: "spring", stiffness: 300, damping: 25 }}
      >
        <Image src={photo.url} />
      </motion.div>
    </motion.div>
  )}
</AnimatePresence>

背景淡入(0.25s),图片内容使用弹簧动画缩放入场。切换照片时通过 key 变化触发重新动画,exit 保证了往回缩的效果。

友情链接/日记等模态框

// app/friends/page.tsx
<AnimatePresence>
  {user && (
    <motion.div
      initial={{ opacity: 0, scale: 0.9, y: 20 }}
      animate={{ opacity: 1, scale: 1, y: 0 }}
      exit={{ opacity: 0, scale: 0.9, y: 20 }}
    >
      <UserDetail />
    </motion.div>
  )}
</AnimatePresence>

这个模式在 DogDiary 的日记详情、friends 的好友详情中一致:从 0.9 倍缩放 + 20px 偏移弹入,关闭时缩回。

径向菜单 RadialMenu

一个比较酷的交互,按下 Tab 键菜单项从中心飞出:

// components/ui/RadialMenu.tsx
<AnimatePresence>
  {isOpen && items.map((item, i) => (
    <motion.div
      key={item.id}
      initial={{ scale: 0, x: centerX, y: centerY }}
      animate={{ scale: 1, x: targetX - 32, y: targetY - 32 }}
      exit={{ scale: 0, x: centerX, y: centerY }}
      transition={{
        type: "spring",
        stiffness: 400,
        damping: 25,
        delay: i * 0.02,
      }}
    >
      {item.icon}
    </motion.div>
  ))}
</AnimatePresence>

每个菜单项从中心点 (centerX, centerY) 飞向目标位置,弹簧参数 stiffness: 400 保证了弹出的干脆利落。delay: i * 0.02 让项依次弹出,有层次感。

6. layoutID 与导航栏指示器

导航栏的活动链接指示器是最精巧的动画之一:

// components/layout/Navbar.tsx
{links.map((link) => (
  <Link key={link.href} href={link.href}>
    <span onClick={() => setActive(link.href)}>{link.label}</span>
    {active === link.href && (
      <motion.div
        layoutId="navbar-indicator"
        transition={{ type: "spring", stiffness: 350, damping: 30 }}
      />
    )}
  </Link>
))}

layoutId="navbar-indicator" 是关键——当 active 切换时,framer-motion 会在 DOM 中查找相同 layoutId 的元素,自动计算位置差,生成平滑的滑动动画。不需要手动跟踪 left/width。

设置面板弹出也用了 AnimatePresence:

<AnimatePresence>
  {isOpen && (
    <motion.div
      initial={{ opacity: 0, y: -10, scale: 0.95 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      exit={{ opacity: 0, y: -10, scale: 0.95 }}
      transition={{ duration: 0.2 }}
    >
      {/* 设置面板 */}
    </motion.div>
  )}
</AnimatePresence>

7. 搜索筛选与 layout 动画

项目列表的搜索筛选是 layout + AnimatePresence 最典型的配合:

// app/projects/page.tsx
<motion.div layout>
  <AnimatePresence>
    {filteredProjects.map((project) => (
      <motion.div
        key={project.id}
        layout
        initial={{ opacity: 0, scale: 0.95, y: 20 }}
        animate={{ opacity: 1, scale: 1, y: 0 }}
        exit={{ opacity: 0, scale: 0.95, y: -20 }}
        transition={{ duration: 0.4, ease: "easeOut" }}
      >
        <ProjectCard project={project} />
      </motion.div>
    ))}
  </AnimatePresence>
</motion.div>

筛选改变时,消失的卡片往上缩走,新出现的卡片从下弹入,剩余的卡片通过 layout 自动平滑移动位置填补空隙。不需要手动计算任何位置。

8. 阅读进度与目录

文章阅读页有三个动画模块:

// components/ui/ReadingProgress.tsx
{/* 顶部进度条 */}
<motion.div
  className="fixed top-0 left-0 h-0.5 bg-sky-500"
  initial={{ width: 0 }}
  animate={{ width: `${progress}%` }}
  transition={{ duration: 0.1 }}
/>

{/* 目录面板:右侧滑入 */}
<AnimatePresence>
  {showToc && (
    <motion.div
      initial={{ opacity: 0, x: 20, scale: 0.95 }}
      animate={{ opacity: 1, x: 0, scale: 1 }}
      exit={{ opacity: 0, x: 20, scale: 0.95 }}
      transition={{ duration: 0.2 }}
    >
      <TOC />
    </motion.div>
  )}
</AnimatePresence>

{/* 回到顶部 */}
<AnimatePresence>
  {showBackToTop && (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: 20 }}
    >
      ↑
    </motion.div>
  )}
</AnimatePresence>

9. 拖拽交互

浮动音乐播放器

// components/music/FloatingPlayer.tsx
<motion.div drag dragMomentum={false} dragElastic={false}>
  {/* 播放器内容 */}
</motion.div>

最简单的拖拽用法。关闭动量(dragMomentum={false})和弹性(dragElastic={false}),让播放器像固定浮窗一样定位。

时间河流拖拽

时间线页面支持水平拖拽浏览,结合了 SVG 路径动画:

// app/timeline/page.tsx
{/* SVG 河流路径描边动画 */}
<motion.path
  d={riverPath}
  initial={{ pathLength: 0 }}
  animate={{ pathLength: 1 }}
  transition={{ duration: 1.8, ease: "easeInOut" }}
/>

{/* 可拖拽容器 */}
<motion.div
  drag="x"
  dragMomentum
  dragElastic={0.1}
  dragConstraints={{
    left: -(totalWidth - containerWidth),
    right: 0,
  }}
>
  {/* 时间线卡片 */}
</motion.div>

dragConstraints 限制拖拽范围在内容的实际宽度内。三条河流路径用 pathLength 动画依次绘制出来(每条 1.8s)。

说说卡片轮播

首页的最新说说轮播用到了更复杂的拖拽计算:

// components/home/LatestChatterCarousel.tsx
<motion.div
  animate={{ scale, y, x, rotate, opacity, zIndex }}
  transition={isDragging ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 30 }}
  // 拖拽时关闭过渡,释放时弹簧回弹
>

拖拽过程中实时计算每张卡片的偏移、旋转、缩放、透明度,形成堆叠卡片的"叠叠乐"效果。transition 根据 isDragging 切换:拖拽中无过渡(即时跟随手指),释放后弹簧回弹或翻到下一张。

10. 相册展开

相册封面扇形动画

使用 variants 管理多状态:

// components/photos/AlbumCard.tsx
const variants = {
  rest: (i: number) => ({
    rotate: i * 4 - 4,
    x: i * 2,
  }),
  hover: (i: number) => ({
    rotate: i * 15 - 30,
    x: i * 20 - 40,
  }),
};

<motion.div
  custom={index}
  initial="rest"
  animate={isExpanded ? "hover" : "rest"}
  whileHover="hover"
  transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
  <Image />
</motion.div>

封面照片在 rest(堆叠)和 hover(扇形展开)状态之间弹簧切换。custom prop 传递索引,让不同照片展开角度不同。

照片网格展开

<AnimatePresence>
  {isExpanded && (
    <motion.div
      initial={{ height: 0, opacity: 0 }}
      animate={{ height: "auto", opacity: 1 }}
      exit={{ height: 0, opacity: 0 }}
      transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
    >
      <div className="grid grid-cols-3">{photos}</div>
    </motion.div>
  )}
</AnimatePresence>

height: "auto" + overflow-hidden 实现自动高度展开动画,展开时间用了自定义贝塞尔曲线 [0.25, 0.1, 0.25, 1](即 ease-in-out 的变体)。

11. 欢迎屏幕

多阶段交错动画:

// components/layout/WelcomeScreen.tsx
<AnimatePresence>
  {show && (
    <motion.div exit={{ opacity: 0 }}>
      {/* 模糊背景 */}
      <motion.div
        initial={{ scale: 1.2, opacity: 0 }}
        animate={{ scale: 1, opacity: 0.2 }}
        transition={{ duration: 1.2 }}
      />
  
      {/* 欢迎文字 */}
      <motion.p
        initial={{ opacity: 0, y: 30 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ delay: 0.3, duration: 0.6 }}
      >
        你好 👋
      </motion.p>

      {/* 站点名称 */}
      <motion.div
        initial={{ opacity: 0, scale: 0.8 }}
        animate={{ opacity: 1, scale: 1 }}
        transition={{ delay: 0.8, duration: 0.5 }}
      >
        Kirameku
      </motion.div>

      {/* 装饰线 */}
      <motion.div
        initial={{ width: 0, opacity: 0 }}
        animate={{ width: 160, opacity: 1 }}
        transition={{ delay: 1.3, duration: 0.8 }}
      />
    </motion.div>
  )}
</AnimatePresence>

每个子元素有不同的 delay,依次登场:背景(0s)→ 欢迎文字(0.3s)→ 站点名(0.8s)→ 装饰线(1.3s)。整个屏幕最终通过父级的 exit={{ opacity: 0 }} 一起淡出。

总结

Kirameku 项目中 framer-motion 的使用可以归纳为几个常见模式:

模式 核心 API 使用场景
淡入入场 initial + animate + delay 列表、卡片、页面元素
消失动画 AnimatePresence + exit 弹窗、Toast、模态框
弹簧交互 whileHover + type: "spring" 悬停效果、卡片反馈
共享布局 layoutId 导航栏指示器
自适应布局 layout 筛选/搜索、可排序列表
拖拽 drag + dragConstraints 窗品浮动、时间轴滚动
路径动画 pathLength SVG 描边
多状态管理 variants + custom 相册扇形展开
拖拽计算 动态animate 对象 卡片轮播堆叠

选 framer-motion 而不是纯 CSS 动画的原因:状态驱动的声明式 API——不需要手动管理 animation 生命周期,不需要 className 切换,只需要描述"这个状态长什么样"。特别是 AnimatePresence 和 layoutId 这类 CSS 很难优雅实现的能力,才是它真正的价值所在。