最新文章
用 HTML + CSS + JS 实现杂志翻页效果
翻页阅读是数字杂志、电子书、产品画册最经典的交互形式。它既要有纸媒的仪式感,又要在浏览器里跑得流畅。本文从渲染原理出发,带你从零实现一个可用的 3D 翻页组件,并讨论几个工程上绕不开的坑。
一、技术选型:三种实现路线
在动手之前,先明确有哪些可选方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| CSS 3D Transform | rotateY + perspective | 实现简单、可访问性好、文字可选中 | 只能做刚性平面翻页,做不出纸张弯曲 |
| Canvas 2D | 逐帧绘制页面变形 | 可以做软翻页(curl)、任意形变 | 文字不可选中、清晰度受 DPR 影响 |
| WebGL | 纹理网格顶点位移 | 效果最接近真实纸张 | 成本最高,移动端兼容与性能需打磨 |
绝大多数杂志站用的是第一条路线:DOM + CSS 3D。原因很现实——杂志内容含大量文字、图片、甚至嵌入视频和表单,只有 DOM 方案能让这些内容保持可用。本文也以这条路线为主线。
二、核心原理:三个关键概念
1. 透视(perspective)
perspective 定义了"观察者到 z=0 平面的距离"。值越小,近大远小越夸张:
.book {
perspective: 2400px; /* 约等于真实翻书的观感 */
perspective-origin: 50% 50%; /* 观察点在书脊中央 */}小于 1000px 会明显"镜头贴脸",大于 4000px 又接近正交投影、失去立体感。2000~3000px 是最舒服的区间。
2. 旋转轴(transform-origin)
一张纸绕哪里转?答案是书脊。所以在布局上,把每张纸定位到书脊右侧,然后把旋转原点设在它的左边缘:
.leaf {
position: absolute;
left: 50%; /* 左边缘正好落在书脊上 */
width: 50%;
height: 100%;
transform-origin: left center; /* 绕书脊旋转 */}这样 rotateY(-180deg) 就等价于"把这一页从右翻到左"。
3. 背面隐藏(backface-visibility)
这是正反面分离的关键。每张纸由前后两个面组成,背面元素预先旋转 180°:
.leaf__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;}.leaf__face--back {
transform: rotateY(180deg);}逻辑是这样的:纸旋转 0° 时,正面朝向你;旋转 -180° 时,正面背向你被隐藏,而背面元素因预先转了 180°,此时正好正对你 —— 于是你就看到了"这一页的反面"。不需要 JS 去切换内容,纯 CSS 自动完成。
三、完整实现
1. HTML 结构
只需要一个容器,页面由 JS 动态生成:
<div class="book" id="book"></div><nav class="controls"> <button id="btnPrev" aria-label="上一页">‹</button> <span id="indicator">1 / 6</span> <button id="btnNext" aria-label="下一页">›</button></nav>
2. CSS
* { box-sizing: border-box; }body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: radial-gradient(circle at 50% 40%, #2b2f38, #14161a 70%);
font-family: "Georgia", "Songti SC", serif;
color: #222;}/* ---------- 书本容器:提供透视 ---------- */.book {
position: relative;
width: min(92vw, 900px);
aspect-ratio: 3 / 2; /* 每页比例 3:4,接近杂志开本 */
perspective: 2400px;
perspective-origin: 50% 50%;
user-select: none;
touch-action: pan-y; /* 允许纵向滚动,横向交给手势 */}/* 书脊阴影(纯装饰) */.book::after {
content: "";
position: absolute;
left: 50%;
top: 0;
width: 44px;
height: 100%;
transform: translateX(-50%);
background: linear-gradient(to right,
transparent,
rgba(0,0,0,.16) 45%,
rgba(0,0,0,.16) 55%,
transparent);
pointer-events: none;
z-index: 9999;}/* ---------- 一张纸 ---------- */.leaf {
position: absolute;
top: 0;
left: 50%;
width: 50%;
height: 100%;
transform-origin: left center;
transform-style: preserve-3d;
transition: transform 800ms cubic-bezier(.42, .05, .25, 1);
will-change: transform;}.leaf.is-flipped {
transform: rotateY(-180deg);}/* ---------- 纸张的正反面 ---------- */.leaf__face {
position: absolute;
inset: 0;
overflow: hidden;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
background: #fdfcf8;
box-shadow: 0 8px 28px rgba(0, 0, 0, .35);}.leaf__face--front {
border-radius: 0 4px 4px 0;
background-image: linear-gradient(to left, rgba(0,0,0,.10), transparent 18%);}.leaf__face--back {
border-radius: 4px 0 0 4px;
transform: rotateY(180deg);
background-image: linear-gradient(to right, rgba(0,0,0,.10), transparent 18%);}/* ---------- 页面内容排版 ---------- */.page {
position: absolute;
inset: 0;
padding: 9% 11%;
display: flex;
flex-direction: column;
gap: 12px;}.page__index {
font: 600 13px/1 ui-monospace, monospace;
letter-spacing: .18em;
color: #b08d57;}.page__title {
margin: 0;
font-size: clamp(16px, 2.2vw, 26px);
line-height: 1.3;}.page__body {
margin: 0;
font-size: clamp(11px, 1.15vw, 14px);
line-height: 1.9;
color: #4a4a4a;
column-count: 2; /* 报纸式多栏 */
column-gap: 22px;
text-align: justify;}/* ---------- 降级:尊重「减少动效」偏好 ---------- */@media (prefers-reduced-motion: reduce) {
.leaf { transition-duration: 1ms; }}3. JavaScript
const book = document.getElementById('book');const indicator = document.getElementById('indicator');const TOTAL_PAGES = 12; // 总页数,必须是偶数const FLIP_MS = 800; // 与 CSS transition 时长保持一致const leaves = []; // 每张纸对应 DOM 元素let flippedCount = 0; // 已翻过去的纸张数量/* ---------- 1. 生成 DOM ---------- */for (let i = 0; i < TOTAL_PAGES / 2; i++) {
const leaf = document.createElement('div');
leaf.className = 'leaf';
// 第 i 张纸:正面 = 第 2i 页,背面 = 第 2i+1 页
leaf.innerHTML = `
<div class="leaf__face leaf__face--front">${pageHTML(i * 2)}</div>
<div class="leaf__face leaf__face--back">${pageHTML(i * 2 + 1)}</div>
`;
book.appendChild(leaf);
leaves.push(leaf);}function pageHTML(n) {
return `
<article class="page">
<span class="page__index">${String(n + 1).padStart(2, '0')}</span>
<h2 class="page__title">章节标题 ${n + 1}</h2>
<p class="page__body">
这里是杂志正文内容。杂志排版的精髓在于分栏、留白与图文的节奏感。
通过 CSS 的 column-count 属性,可以低成本模拟出报刊式的两栏布局,
再配合 serif 字体与宽松的行高,阅读体验会非常接近纸媒。
</p>
</article> `;}/* ---------- 2. 层级管理(本方案最容易出错的地方) ---------- */function updateStack() {
const n = leaves.length;
leaves.forEach((leaf, i) => {
if (leaf.classList.contains('is-flipping')) return; // 动画中的不参与
// 未翻的纸:索引越小越靠上(第 1 张盖住第 2 张)
// 已翻的纸:索引越大越靠上(后翻的盖住先翻的)
leaf.style.zIndex = i < flippedCount ? i + 1 : 100 + (n - i);
});}/* ---------- 3. 翻页动作 ---------- */function next() {
if (flippedCount >= leaves.length) return;
const leaf = leaves[flippedCount];
leaf.classList.add('is-flipping');
leaf.style.zIndex = 999; // 动画期间置顶,避免穿模
flippedCount++;
leaf.classList.add('is-flipped');
settle(leaf);}function prev() {
if (flippedCount <= 0) return;
flippedCount--;
const leaf = leaves[flippedCount];
leaf.classList.add('is-flipping');
leaf.style.zIndex = 999;
leaf.classList.remove('is-flipped');
settle(leaf);}/* 动画结束后收尾(transitionend 可能因中断不触发,加兜底定时器) */function settle(leaf) {
let done = false;
const finish = () => {
if (done) return;
done = true;
leaf.classList.remove('is-flipping');
updateStack();
updateIndicator();
};
leaf.addEventListener('transitionend', finish, { once: true });
setTimeout(finish, FLIP_MS + 80);}function updateIndicator() {
indicator.textContent = `${Math.min(flippedCount + 1, leaves.length)} / ${leaves.length}`;}/* ---------- 4. 交互:点击 / 手势 / 键盘 ---------- */book.addEventListener('click', (e) => {
const rect = book.getBoundingClientRect();
(e.clientX - rect.left > rect.width / 2) ? next() : prev();});let startX = null;book.addEventListener('pointerdown', (e) => { startX = e.clientX; });book.addEventListener('pointerup', (e) => {
if (startX === null) return;
const dx = e.clientX - startX;
if (dx < -40) next();
else if (dx > 40) prev();
startX = null;});document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); next(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); }});document.getElementById('btnNext').onclick = next;document.getElementById('btnPrev').onclick = prev;/* ---------- 5. 初始化 ---------- */updateStack();updateIndicator();四、三个必须知道的坑
坑 1:z-index 不能交给浏览器
很多人以为开了 preserve-3d,浏览器就会按 3D 深度自动排序。实际上,一旦元素带有 opacity、filter、transform 等属性,就会创建新的堆叠上下文,排序规则会变得难以预测,表现为翻页过程中"纸从书里穿过去"。
结论:显式管理 z-index。 规则很简单:
还没翻的纸 —— 索引越小越靠上
已经翻过去的纸 —— 索引越大越靠上
正在动画的那张 —— 临时给一个极大值
坑 2:动画中断导致状态卡死
用户快速连点"下一页"时,transitionend 可能不会触发(动画被新的动画打断)。如果不做兜底,is-flipping 类会永远留在元素上,导致 z-index 再也无法更新。
上面的 settle() 里用 setTimeout 做兜底 + done 标志位防重入,就是为了解决这个问题。
坑 3:移动端的合成层爆炸
每张纸都是独立的合成层。页数一多(比如 60 页的杂志),GPU 内存会急剧上升,低端安卓机直接掉帧。
优化建议:
只保留相邻 2~3 张纸在 DOM 里,其余按需创建 / 回收,或改用
content-visibility: auto。will-change: transform不要常驻,翻页前加上、结束后移除。移动端把
perspective调大一点(比如 3000px),减少透视带来的重绘面积。大图用
loading="lazy"且设置width/height,避免翻页时触发布局抖动。
五、想要更真实的纸感?
纯 rotateY 得到的是刚性平面翻页,纸不会有弯曲。想要真正的"纸感",需要更进一步的方案:
方案 A:分段变形(CSS)
把一页切成 N 条竖直薄片,每条独立旋转。成本高、接缝明显,实践中很少用。
方案 B:Canvas 软翻页
用贝塞尔曲线定义翻页的"卷曲"轮廓,再逐像素采样原图做映射。效果很好,但文字无法选中,且高分屏需要按 DPR 放大画布。
方案 C:WebGL 网格变形
把页面渲染成纹理贴到网格上,用顶点着色器做卷曲。效果天花板最高。
方案 D:直接用成熟库
| 库 | 特点 |
|---|---|
| StPageFlip | 现代、零依赖、Canvas 渲染,支持软翻页与硬翻页,移动端手感好 |
| turn.js | 老牌方案,基于 jQuery,生态成熟但久未更新 |
| PageFlip (react/page-flip) | React 生态首选,API 友好 |
| PDF.js + 上述任一库 | 常见的 PDF 翻页阅读器组合 |
如果你的需求只是"杂志 H5 里能翻页、内容可交互",那本文这套 DOM 方案足够了;如果追求视觉表现力(比如品牌宣传页),再考虑 Canvas / WebGL。
六、小结
翻页效果的复杂度其实不在"翻"这个动作,而在于层级管理、动画状态机、性能边界这三件事上。核心代码不到 100 行,但每一条 z-index 规则、每一个 transitionend 兜底,都是踩过坑之后才补上去的。
建议的落地路径:
先用本文的 DOM 方案跑通主流程,验证内容排版
加上手势、键盘、无障碍属性(
aria-label、焦点管理)页数超过 20 页时,引入虚拟化,只保留相邻纸张
视觉要求高时,再迁移到 StPageFlip 或 WebGL 方案












冀公网安备