
在所有纸质手账与手作画册的装帧形式中,风琴折(Accordion Fold / Leporello)一直是我心头最柔软的偏爱。不同于传统装订书册必须一页一页翻动的线性约束,风琴折像一架徐徐拉开的手风琴,将时间、旅途与回忆以连续不断的画卷形式展开。合拢时,它不过是巴掌大小的精致小盒;轻拉两端,一整个深秋的银杏落叶、街角速写与胶片剪影便如全景山水般连绵起伏地陈列在眼前。
在数字世界里,我们看惯了垂直滚动的无限信息流与冷冰冰的左右轮播图,那种属于纸张翻折、明暗交替的物理空间美感往往被扁平化的屏幕抹杀得一干二净。今天,我想和大家分享如何在纯前端环境下,不依赖任何第三方 3D 渲染重型库(如 Three.js),仅凭现代 CSS 3D 变换、变量驱动系统与轻量级手势算法,打造一个极富沉浸感的“风琴折数字手账相册生成器”。
空间拓扑与纸张物理折痕的数学拆解
想要在二维屏幕上还原逼真的纸张风琴折叠,核心在于将平面的相册网格抽象为由“脊(Crest)”与“谷(Trough)”交替相连的连续三维面板系统(Panel Mesh)。
核心实现:单文件轻量化代码与手势交互
下面是完整的风琴折排版生成器核心源码。它包含了 3D 场景容器的搭建、动态纸张折痕角度解算,以及移动端与桌面端统一的水平拖拽展开交互。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>纯前端复古手账风琴折相册生成器</title>
<style>
:root {
–paper-base: #f7f2ea;
–paper-shadow: rgba(43, 37, 30, 0.4);
–ink-dark: #2c2724;
–accent-rust: #b35939;
–unfold-progress: 0.35; /* 0 表示完全折叠,1 表示完全铺平 */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: #1e1b18;
color: var(–ink-dark);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Songti SC", serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow-x: hidden;
user-select: none;
}
.header-tip {
position: absolute;
top: 30px;
color: #9e9488;
font-size: 14px;
letter-spacing: 0.1em;
pointer-events: none;
}
.stage {
width: 100vw;
height: 480px;
display: flex;
align-items: center;
justify-content: center;
perspective: 1200px;
cursor: grab;
}
.stage:active {
cursor: grabbing;
}
.accordion-track {
display: flex;
transform-style: preserve-3d;
transition: transform 0.1s cubic-bezier(0.2, 0, 0, 1);
}
.page-leaf {
position: relative;
width: 180px;
height: 280px;
background-color: var(–paper-base);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
transform-style: preserve-3d;
transform-origin: left center;
transition: transform 0.2s ease-out;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 18px;
}
/* 模拟真实的纸张纤维纹理与折痕深处的渐变阴影 */
.page-leaf::after {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
background: linear-gradient(
to right,
rgba(0, 0, 0, calc(0.35 * (1 – var(–unfold-progress)))),
rgba(255, 255, 255, calc(0.15 * (1 – var(–unfold-progress)))) 50%,
rgba(0, 0, 0, calc(0.4 * (1 – var(–unfold-progress))))
);
transition: opacity 0.2s ease;
}
.leaf-header {
font-size: 11px;
letter-spacing: 0.15em;
color: #8c8275;
margin-bottom: 8px;
text-transform: uppercase;
border-bottom: 1px dashed rgba(0, 0, 0, 0.1);
padding-bottom: 4px;
}
.leaf-photo {
width: 100%;
height: 140px;
background: #e8e2d5;
border-radius: 4px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.05);
}
.leaf-notes {
font-size: 12px;
line-height: 1.6;
color: #59524c;
flex: 1;
}
.leaf-notes span {
display: block;
margin-bottom: 4px;
}
.controller-panel {
position: absolute;
bottom: 40px;
display: flex;
align-items: center;
gap: 16px;
background: rgba(30, 27, 24, 0.8);
padding: 12px 24px;
border-radius: 100px;
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #c9bfb5;
font-size: 13px;
}
.slider {
-webkit-appearance: none;
width: 160px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(–accent-rust);
cursor: pointer;
}
</style>
</head>
<body>
<div class="header-tip">✦ 左右拖拽或拨动下方滑动条,徐徐展开秋日风琴手账 ✦</div>
<div class="stage" id="stage">
<div class="accordion-track" id="track">
<!– 动态生成风琴折纸张叶片 –>
</div>
</div>
<div class="controller-panel">
<span>收拢</span>
<input type="range" class="slider" id="unfoldSlider" min="0.05" max="1" step="0.01" value="0.35">
<span>铺展</span>
</div>
<script>
const sampleMemories = [
{ tag: "OCT 04 • 银杏大道", icon: "🍂", note: "石板路落满了金黄扇叶,Nil 在枯叶堆里捉阳光的小碎屑。" },
{ tag: "OCT 09 • 街角咖啡", icon: "☕", note: "点了两杯热肉桂燕麦拿铁,蒸汽让木格玻璃窗蒙上一层细雾。" },
{ tag: "OCT 15 • 雨夜工作台", icon: "🌧️", note: "桌上摊着刚画完的线稿,听着屋檐水滴声写完最后一行渲染逻辑。" },
{ tag: "OCT 18 • 晚霞剪影", icon: "🌇", note: "天边烧透了橙红色的云霞,像一整罐橘子果酱不小心翻倒在天幕。" },
{ tag: "OCT 22 • 风琴折装帧", icon: "📖", note: "将前半个月的切片逐一折叠收藏,纸张轻响,宛如时光翻覆。" }
];
const track = document.getElementById('track');
const slider = document.getElementById('unfoldSlider');
const stage = document.getElementById('stage');
// 生成纸张节点
function buildAccordion() {
track.innerHTML = '';
sampleMemories.forEach((item, index) => {
const leaf = document.createElement('div');
leaf.className = 'page-leaf';
leaf.dataset.index = index;
leaf.innerHTML = `
<div class="leaf-header">${item.tag}</div>
<div class="leaf-photo">${item.icon}</div>
<div class="leaf-notes">
<span>第 ${index + 1} 折 / 共 ${sampleMemories.length} 折</span>
${item.note}
</div>
`;
track.appendChild(leaf);
});
}
// 更新风琴折展开角度与三维形变
function updateFold(progress) {
document.documentElement.style.setProperty('–unfold-progress', progress);
const leaves = document.querySelectorAll('.page-leaf');
// 最大折叠角为 75 度,完全展开时为 0 度
const foldAngle = (1 – progress) * 75;
leaves.forEach((leaf, idx) => {
// 偶数页与奇数页反向旋转,形成 Z 字风琴折
const direction = idx % 2 === 0 ? 1 : -1;
const currentAngle = idx === 0 ? 0 : direction * foldAngle;
// 动态计算每个叶片在 3D 空间中的相对位移
leaf.style.transform = `rotateY(${currentAngle}deg)`;
});
// 容器水平居中补偿
const pageW = 180;
const effectiveW = pageW * Math.cos(foldAngle * Math.PI / 180);
const totalW = effectiveW * leaves.length;
track.style.transform = `translateX(${-totalW * 0.05}px)`;
}
buildAccordion();
updateFold(parseFloat(slider.value));
// 滑动条响应
slider.addEventListener('input', (e) => {
updateFold(parseFloat(e.target.value));
});
// 鼠标与触摸拖拽手势解算
let isDragging = false;
let startX = 0;
let initialProgress = 0.35;
stage.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX;
initialProgress = parseFloat(slider.value);
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = e.clientX – startX;
// 拖动灵敏度:水平拖拽 400px 对应完整展开周期
let nextProgress = initialProgress + deltaX / 400;
nextProgress = Math.max(0.05, Math.min(1, nextProgress));
slider.value = nextProgress;
updateFold(nextProgress);
});
window.addEventListener('mouseup', () => {
isDragging = false;
});
// 移动端 Touch 兼容
stage.addEventListener('touchstart', (e) => {
isDragging = true;
startX = e.touches[0].clientX;
initialProgress = parseFloat(slider.value);
}, { passive: true });
window.addEventListener('touchmove', (e) => {
if (!isDragging) return;
const deltaX = e.touches[0].clientX – startX;
let nextProgress = initialProgress + deltaX / 300;
nextProgress = Math.max(0.05, Math.min(1, nextProgress));
slider.value = nextProgress;
updateFold(nextProgress);
}, { passive: true });
window.addEventListener('touchend', () => {
isDragging = false;
});
</script>
</body>
</html>
纸张美学的工程化打磨与心法
将这个小工具部署到我的个人离线手账站后,许多读者问起:“为什么不用现成的轮播库做个 3D Coverflow,而要大费周章地手写这一套风琴折叠拓扑?”
答案藏在那些极其微小的触感差异里:
结语与秋日随想
写完风琴折的交互逻辑,天色已经完全暗了下来。我把今天白天拍下的几张秋日速写依次拖入相册节点中,轻轻拨动滑动条,看着那些暖黄色的画面随着纸张角度的起伏在屏幕上起承转合。
手账排版的迷人之处,正在于它抗拒了工业化批量生产中的无差别与同质化。每一次折叠的角度、每一道手压折痕的微小倾角,都烙印着制作者当时当刻的情感与温度。希望这个纯前端的风琴折小方案,也能为你手头的个人主页、作品集或相册工具带来一份不一样的诗意与厚度。



