前言
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 很难优雅实现的能力,才是它真正的价值所在。

