用 React 手搓一个 3D 翻页书籍组件,页角还能卷起来!从零到踩坑全记录
前端开发中,你是否也想过把枯燥的内容展示做得像翻书一样?本文记录了我从零开发一个 3D 交互式书籍组件 的完整过程——包括 CSS 3D 翻页、拖拽手势、页角海浪卷起效果,以及中间踩过的坑和最终的解决方案。
一、为什么要做这个组件?
在做一个 AI 知识库产品时,产品经理提了一个需求:
「能不能把教程做成一本可以翻页的书?用户点击或拖拽就能翻页,体验要像真书。」
市面上的轮播图、Tab 切换都太「平」了,我希望做一个有纵深感的 3D 翻书交互。翻遍了 npm,要么功能太简陋,要么依赖 Canvas 体积太大,最终决定——自己写一个。
目标很明确:
- 🎨 CSS 3D 实现真实翻页效果,不用 Canvas
- ✋ 支持拖拽翻页、点击翻页、键盘翻页
- 🌊 鼠标悬停页角时有「海浪卷起」的视觉提示
- 📱 移动端触摸支持
- 🧱 纯 React 组件,零外部翻书依赖
二、架构设计:一本书的 DOM 结构
先想清楚一本书的物理结构:
┌─────────────────────────────────┐
│ Container │ ← perspective: 2000px 提供 3D 视角
│ ┌───────────────────────────┐ │
│ │ BookWrapper │ │ ← 打开时 translateX(50%) 居中
│ │ ┌─────────────────────┐ │ │
│ │ │ Cover │ │ │ ← rotateY(-180deg) 翻开
│ │ │ ┌ front ┐┌ back ─┐ │ │ │
│ │ │ │封面图片││内封页 │ │ │ │
│ │ │ └───────┘└───────┘ │ │ │
│ │ ├─────────────────────┤ │ │
│ │ │ Pages │ │ │ ← 所有页面叠在一起
│ │ │ ┌ Page 1 ────────┐ │ │ │
│ │ │ │ front │ back │ │ │ │ ← 每页双面
│ │ │ └────────────────┘ │ │ │
│ │ │ ┌ Page 2 ────────┐ │ │ │
│ │ │ │ front │ back │ │ │ │
│ │ │ └────────────────┘ │ │ │
│ │ │ ┌ BackCover ─────┐ │ │ │
│ │ │ │ The End │ │ │ │
│ │ │ └────────────────┘ │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
│ Navigation Bar │
└─────────────────────────────────┘
核心思路:
- 每一页都是绝对定位叠在一起,
transform-origin: left center,翻页就是绕左边缘旋转 -180° - 用
backface-visibility: hidden+ 前后两个 div 模拟正反面 - 通过
zIndex控制翻过的页和未翻的页的层叠关系
三、核心实现
3.1 CSS 3D 翻页
关键 CSS:
.container {
perspective: 2000px; // 3D 视角距离
}
.page {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: left; // 绕左边轴翻转
}
.pageFront, .pageBack {
backface-visibility: hidden; // 只显示朝向用户的面
}
.pageBack {
transform: rotateY(180deg) translateZ(0.5px); // 背面翻转 180°
}
用 Framer Motion 的 variants 控制翻转动画:
const variants = {
flipped: {
rotateY: -180,
zIndex: isBuriedLeft ? index + 1 : pages.length + 10,
transition: {
rotateY: { duration: 0.6, ease: [0.645, 0.045, 0.355, 1] },
zIndex: { delay: 0.6 },
},
},
unflipped: {
rotateY: 0,
zIndex: pages.length - index,
transition: {
rotateY: { duration: 0.6, ease: [0.645, 0.045, 0.355, 1] },
zIndex: { delay: 0.6 },
},
},
}
这里的贝塞尔曲线 [0.645, 0.045, 0.355, 1] 是精心调的,模拟纸张翻页时先快后慢的物理感。
3.2 拖拽翻页
参考电子书阅读器的拖拽逻辑:
// mousedown → 记录起点
// mousemove → 计算偏移,用 rAF 优化性能
// mouseup → 偏移超过阈值(80px)则触发翻页
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging) return
currentDragXRef.current = e.clientX
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = requestAnimationFrame(() => {
setDragOffset(currentDragXRef.current - dragStartXRef.current)
})
}, [isDragging])
拖拽过程中,当前页面会有一个「弓起」效果:
const curlAngle = isActiveDragPage
? Math.min(Math.abs(dragOffset) * 0.25, 45) * (dragOffset < 0 ? -1 : 1)
: 0
const curlZ = isActiveDragPage
? Math.min(Math.abs(dragOffset) * 0.15, 30)
: 0
根据拖拽偏移量,页面最多弓起 45°,同时沿 Z 轴抬升 30px,配合 box-shadow 产生投影,效果非常逼真。
3.3 页角海浪卷起效果 🌊
这是整个组件最有趣的交互细节:鼠标悬停在页角时,纸张会像海浪一样卷起来,提示用户「这里可以翻页」。
实现原理:在页面的右下角/左下角放置 80×80 的热区,hover 时用 border-radius: 100% + 渐变背景模拟卷角,配合 CSS @keyframes 实现呼吸式波浪动画。
.cornerZone {
position: absolute;
width: 80px;
height: 80px;
cursor: pointer;
}
.curlEffect {
width: 0;
height: 0;
transition: width 0.35s cubic-bezier(0.34, 1.56, 0.64, 1),
height 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
// hover 时展开卷角
.cornerActive .curlEffect {
width: 55px;
height: 55px;
}
卷角的渐变模拟了纸张翻起时的明暗变化:
.cornerBottomRight .curlEffect {
background: linear-gradient(
225deg,
rgba(253, 251, 247, 0.95) 0%, // 翻起的纸面(亮)
rgba(253, 251, 247, 0.9) 35%,
rgba(230, 225, 215, 0.85) 50%, // 折痕处(暗)
rgba(200, 195, 185, 0.4) 70%,
transparent 100% // 渐隐到背景
);
border-top-left-radius: 100%; // 关键!圆弧形卷角
}
海浪动画通过 @keyframes 让卷角大小在 50px - 70px 之间波动:
@keyframes curlWaveRight {
0% { width: 55px; height: 55px; }
30% { width: 70px; height: 70px; } // 浪涌
60% { width: 50px; height: 50px; } // 回落
100% { width: 55px; height: 55px; } // 归位
}
弹性过渡的贝塞尔曲线 cubic-bezier(0.34, 1.56, 0.64, 1) 让展开有一个「弹一下」的效果,像纸张被风吹起。
四、踩坑实录:那些让我抓狂的 Bug
坑 1:页角点击不触发翻页
现象:鼠标在页角卷起后点击,但页面没有翻动。
原因:mousedown 事件冒泡到了父容器 .pages,触发了拖拽逻辑(isDragging = true)。由于 React 的条件渲染逻辑写了 !isDragging,页角区域立刻被卸载,onClick 根本来不及触发。
解决:在页角热区上阻止 mousedown 冒泡:
<div
className={styles.cornerZone}
onMouseDown={(e) => e.stopPropagation()} // 关键!
onTouchStart={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
setCornerHover('none')
nextPage(e)
}}
>
坑 2:翻到下一页时左侧短暂闪烁
现象:翻页时左侧会短暂显示封面内容,然后才变成当前页的背面。
第一次尝试(失败):用 Framer Motion 的 opacity 动画延迟隐藏已翻过的页面。设置了 delay: 0.65s,等翻转动画完成后再隐藏。
结果:时序不可靠。opacity 依赖 Framer Motion 的 variant 重算,isBuriedLeft 变化时 variant 值立刻更新,无论 delay 多少都可能出现竞态。
最终方案:彻底放弃 opacity 动画,改用 CSS visibility 隐藏深层页面:
// 只隐藏 "深层" 掩埋的页面(index < currentPageIndex - 1)
// 保留紧邻的前一页可见,确保左侧始终有背面内容
const isDeeplyBuried = isFlipped && index < currentPageIndex - 1
<motion.div style={{
visibility: isDeeplyBuried ? 'hidden' : 'visible',
}}>
visibility: hidden 是即时的、无动画的、确定性的——完美解决闪烁问题。
坑 3:翻回上一页时又闪了
现象:修好了向后翻页,但翻回上一页时又出现闪烁。
原因:unflipped variant 的 zIndex transition 的 delay 设为了 0,导致页面还在翻转动画过程中,zIndex 就提前降低了,被其他页面遮挡。
解决:双向翻页的 zIndex 都延迟到动画结束后再更新:
unflipped: {
rotateY: 0,
zIndex: pages.length - index,
transition: {
rotateY: { duration: 0.6, ease: [0.645, 0.045, 0.355, 1] },
zIndex: { delay: 0.6 }, // 和翻页动画时长一致!
},
},
坑 4:最后一页拖不动但光标还是「抓手」
现象:翻到最后一页(The End),虽然结束页已经阻止了事件冒泡,但在页面空白区域鼠标仍然显示 grab 光标。
解决:检测最后一页状态,同时禁用拖拽逻辑和光标样式:
const isLastPage = currentPageIndex >= pages.length - 1
// 禁用 mousedown
const handleMouseDown = useCallback((e) => {
if (!isOpen || isLastPage) return // 最后一页不触发拖拽
// ...
}, [isOpen, isLastPage])
// 光标
cursor: isOpen
? (isLastPage ? 'default' : isDragging ? 'grabbing' : 'grab')
: 'default'
五、最终效果
组件支持的交互方式一览:
| 交互方式 | 说明 |
|---|---|
| 🖱️ 拖拽翻页 | 按住页面左右拖拽,超过 80px 阈值松手翻页 |
| 🌊 页角点击 | 悬停右下角/左下角出现卷起效果,点击翻页 |
| 🔘 导航栏 | 底部导航栏前后翻页按钮 |
| ⌨️ 键盘 | ← → 翻页 / Escape 关闭 / Home End 跳转 |
| 📱 触摸 | 移动端触摸滑动翻页 |
| 📕 封面 | 点击或向左拖拽打开书籍 |
使用方式非常简单:
import InteractiveBook from '@stateless/InteractiveBook'
<InteractiveBook
coverImage="/cover.jpg"
bookTitle="AI Agent 完全指南"
bookAuthor="AI 专家"
pages={[
{
pageNumber: 1,
title: '第一章',
content: <div>正面内容</div>,
backContent: <div>背面内容</div>,
},
// ...
]}
onPageChange={(index) => console.log('当前页:', index)}
enableKeyboard
/>
六、技术栈总结
| 技术 | 用途 |
|---|---|
| React + TypeScript | 组件逻辑 |
| Framer Motion | 翻页动画、封面动画、导航栏动画 |
| CSS 3D Transform | perspective、rotateY、preserve-3d、backface-visibility |
| CSS Modules (Less) | 样式隔离 |
| requestAnimationFrame | 拖拽性能优化 |
| lucide-react | 图标 |
七、写在最后
一个看似简单的翻书组件,涉及了 CSS 3D 变换、事件冒泡机制、Framer Motion variant 生命周期、zIndex 时序控制 等多个知识点。最大的教训是:
不要用动画属性(opacity/transform)去做「显示/隐藏」这种二元状态控制。 用
visibility或条件渲染——确定性比优雅更重要。
完整代码已开源,欢迎 Star ⭐
GitHub: Pro React Admin
预览地址: Interactive Book
如果这篇文章对你有帮助,别忘了点个赞 👍 收藏一下 📌