用 React 手搓一个 3D 翻页书籍组件,呼吸海浪式翻页,交互体验带感!

0 阅读7分钟

用 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 TransformperspectiverotateYpreserve-3dbackface-visibility
CSS Modules (Less)样式隔离
requestAnimationFrame拖拽性能优化
lucide-react图标

七、写在最后

一个看似简单的翻书组件,涉及了 CSS 3D 变换、事件冒泡机制、Framer Motion variant 生命周期、zIndex 时序控制 等多个知识点。最大的教训是:

不要用动画属性(opacity/transform)去做「显示/隐藏」这种二元状态控制。visibility 或条件渲染——确定性比优雅更重要。

完整代码已开源,欢迎 Star ⭐


GitHub: Pro React Admin

预览地址: Interactive Book

image.png

image.png

如果这篇文章对你有帮助,别忘了点个赞 👍 收藏一下 📌