欢迎光临
我们一直在努力

纯前端复古手账风琴折相册排版生成器:基于 CSS 3D 折痕变换与连续视差展开交互

封面信息图

在所有纸质手账与手作画册的装帧形式中,风琴折(Accordion Fold / Leporello)一直是我心头最柔软的偏爱。不同于传统装订书册必须一页一页翻动的线性约束,风琴折像一架徐徐拉开的手风琴,将时间、旅途与回忆以连续不断的画卷形式展开。合拢时,它不过是巴掌大小的精致小盒;轻拉两端,一整个深秋的银杏落叶、街角速写与胶片剪影便如全景山水般连绵起伏地陈列在眼前。

在数字世界里,我们看惯了垂直滚动的无限信息流与冷冰冰的左右轮播图,那种属于纸张翻折、明暗交替的物理空间美感往往被扁平化的屏幕抹杀得一干二净。今天,我想和大家分享如何在纯前端环境下,不依赖任何第三方 3D 渲染重型库(如 Three.js),仅凭现代 CSS 3D 变换、变量驱动系统与轻量级手势算法,打造一个极富沉浸感的“风琴折数字手账相册生成器”。

空间拓扑与纸张物理折痕的数学拆解

想要在二维屏幕上还原逼真的纸张风琴折叠,核心在于将平面的相册网格抽象为由“脊(Crest)”与“谷(Trough)”交替相连的连续三维面板系统(Panel Mesh)。

  • 交替旋转轴心(Alternating Transform Origin):每一折纸张在展开或收拢时,旋转的中心点是不断变化的。奇数页的折痕向内凹陷,其旋转轴在右边缘(right center);偶数页向外凸起,旋转轴在左边缘(left center)。通过 CSS 嵌套结构或统一的变换矩阵,可以实现链式相对旋转。
  • 动态阴影与微表面高光(Ambient Occlusion & Crease Shadow):纸张之所以看起来有厚度与立体感,是因为折角越小时,折缝深处的环境光吸收越多,会呈现狭长的深色阴影;而背光的纸面会有自然的线性渐变过渡。我们通过 CSS 自定义属性将展开进度(–unfold-ratio)实时映射为渐变遮罩的不透明度。
  • 连续视差透视(Parallax Perspective):风琴折完全拉平时是一条直线,收拢时呈现 Z 字形折叠。当用户拖拽手账两端时,不仅各页之间存在角度变化,相册在屏幕中央的水平位移与视距(perspective)也会产生自然的弹性衰减,带来宛如双手拉开真实风琴折纸张的阻尼感。
  • 核心实现:单文件轻量化代码与手势交互

    下面是完整的风琴折排版生成器核心源码。它包含了 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,而要大费周章地手写这一套风琴折叠拓扑?”

    答案藏在那些极其微小的触感差异里:

  • 纸张的连续感而非断裂感:标准的轮播图(Carousel)在逻辑上将每一张幻灯片视为相互隔离的个体;而风琴折是一整张长纸条通过压痕反复翻折而成的整体。在我们的代码中,通过 transform-style: preserve-3d 与动态余弦投影宽度计算,叶片与叶片之间的缝隙始终咬合在一起,不会发生割裂漂移。
  • 光影对折叠深度的实时自适应:在 CSS 的 ::after 伪元素中,我们没有使用固定的半透明黑色遮罩,而是将 –unfold-progress 注入到线性渐变的关键帧中。当风琴折被深深收拢时,纸面凹折处会呈现浓郁的阴影,凸起处则泛出微光;而当用户完全拉平它时,阴影不透明度自然衰减归零,还原出一张平展细腻的水彩白卡纸。
  • 触手可及的触觉阻尼:在拖拽交互上,我们加入了简单的边界限位与渐进阻尼。手势滑过屏幕时,不仅是数值在递增,由于透视点(perspective: 1200px)的存在,离视点较近的边缘在视觉上放大,远端的折叠在空间深处收拢,营造出宛如端坐在深秋木桌前、亲手拉开一本手作曲谱般的仪式感。
  • 结语与秋日随想

    写完风琴折的交互逻辑,天色已经完全暗了下来。我把今天白天拍下的几张秋日速写依次拖入相册节点中,轻轻拨动滑动条,看着那些暖黄色的画面随着纸张角度的起伏在屏幕上起承转合。

    手账排版的迷人之处,正在于它抗拒了工业化批量生产中的无差别与同质化。每一次折叠的角度、每一道手压折痕的微小倾角,都烙印着制作者当时当刻的情感与温度。希望这个纯前端的风琴折小方案,也能为你手头的个人主页、作品集或相册工具带来一份不一样的诗意与厚度。

    赞(0)
    未经允许不得转载:171主机测评 » 纯前端复古手账风琴折相册排版生成器:基于 CSS 3D 折痕变换与连续视差展开交互
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址