Stripe Press 这套炫酷交互,核心不是 Three.js

发布于 2026年07月14日 00:42 #前端#WebGL 原文链接

Stripe Press 这套炫酷交互,核心不是 Three.js 封面图
  • Stripe Press 采用 DOM Controller 组织页面状态,用 data-js-controller 和 data-js-target 连接模块
  • 首页通过 Three.js WebGL canvas 呈现书籍、电影海报和材质贴图,DOM 继续承载真实文本
  • 书籍交互由滚动、鼠标跟随、拖拽和 History API 共同驱动,形成列表与详情的连续状态切换
  • 播客预览使用 AudioContext、AnalyserNode 和 Canvas 2D 实时绘制频谱柱
  • 最值得借鉴的是分层、统一进度、生命周期管理和 WebGL 降级,而不是单纯堆叠 3D 效果

原文链接:Stripe Press

大家好,我是若风。

前几天打开 press.stripe.com,我本来只是想看看 Stripe Press 最近在卖什么书,结果鼠标刚在页面上晃了两下,就被一排会转、会发光、会跟着滚动变化的书封吸住了。说实话,那一瞬间有点惊喜,明明只是一个出版社首页,手感却像在玩一个小型产品展览。

这类网站很容易让人产生一个错觉:是不是用了 Three.js,顺手堆几个 shader,画面就自然变高级了?如果你曾经因为一次 resize 让整张 3D 卡片消失,那种调试体验还是挺揪心的。

我花了 2 小时,把页面源码、加载的脚本、样式文件和运行时 DOM 都翻了一遍,结论反而没有那么玄学:Stripe Press 的高级感,主要来自分层和编排,而不是某一个神奇的动画库。

它让 DOM 负责真实内容和交互入口,让 WebGL 负责空间里的书、海报和材质,让 CSS 负责菜单、按钮和过渡,再用一个页面级状态机把滚动、拖拽、点击和 URL 串起来。

这套思路,比单纯记住“Three.js 怎么创建一个 Mesh”更值得借鉴。

先看它到底用了什么

这次分析基于 2026 年 7 月 14 日访问到的线上页面。页面的 chunk 名称和内容会随 Stripe 发布而变化,下面引用的是当时能直接观察到的实现线索,不把未公开的内部工程细节当成确定事实。

1. 一套自定义的 DOM Controller

页面不是典型的 React 或 Next.js 输出。HTML 里可以看到很多这样的属性:

<body data-js-controller="PressHomepage">
  <div data-js-controller="PressHomepageCanvas"></div>
  <div data-js-controller="PressHomepageProductList"></div>
</body>

按钮、链接和图片也通过 data-js-targetdata-js-target-list 互相找到。加载器会读取页面里的 script registry,然后按需动态 import 对应的 controller 模块。

从源码命名可以看到几个重要模块:

  • PressHomepage:页面级状态、滚动、鼠标、键盘和 URL 状态。
  • PressHomepageCanvas:WebGL canvas、书籍 Mesh、电影海报和播客场景。
  • PressHomepageProductList:真实的书籍、电影和播客列表。
  • PressHomepageMenu:左侧导航和产品指示器。
  • PressHomepageFilmOverlayPressHomepagePodcastTranscriptOverlay:全屏内容层。

这是一种很朴素、但很有效的架构:控制器只关心自己那一小块 DOM,页面控制器负责跨模块协调。

如果你熟悉 Stimulus、Alpine.js 或一些传统的渐进增强方案,会发现它和“给 HTML 加行为”的路线很接近,只是 Stripe 自己做了模块加载和依赖管理。

2. Three.js 加 WebGL,而不是把页面全画进 Canvas

首页运行时有一个 PressHomepageCanvas,在桌面尺寸下会创建一张全屏 WebGL canvas。页面加载的 v1-Page-4DSXCIGJ.js 又依赖包含 Three.js 的共享 chunk,运行时可以看到 THREE.WebGLRendererShaderMaterialMeshStandardMaterial 等痕迹。

书籍不是一张平面图片贴在 <img> 上。源码里能看到书封的 3D Mesh、相机、灯光,以及一组材质贴图:

  • diffuse:基础颜色。
  • bump:凹凸细节。
  • foil:烫金或金属箔效果。
  • gloss:高光。
  • glitter:颗粒或闪烁效果。

也就是说,鼠标移动时你看到的并不只是 transform: rotate(),而是模型旋转、灯光变化和材质反射共同产生的错觉。

源码里还出现了自定义 vertex shader 和 fragment shader。这里可以做一个重要区分:Three.js 是渲染基础设施,真正决定质感的是几何体、纹理、光照和 shader 参数。

3. Canvas 2D 加 Web Audio 做播客均衡器

播客区域用了另一张 Canvas。它不会一开始就初始化音频,而是在用户点击播放后才创建 AudioContextAnalyserNode,再通过 requestAnimationFrame 读取频率数据,把每个频段画成一根红色竖条。

这条链路非常清楚:

用户点击播放
  → AudioContext
  → MediaElementSource
  → AnalyserNode
  → getByteFrequencyData
  → Canvas 2D 频谱柱

这比循环播放一个提前录制好的动画更有意思,因为动画和声音真的同步,而且只有在需要时才创建音频上下文。

4. CSS 仍然承担了大量“高级感”

页面加载了多个按组件拆分的 CSS 文件,字体也分成 Ivar DisplayIvar HeadlineIvar Text 三组。菜单、按钮、遮罩、背景颜色、链接 hover、面板滑入,并不需要 WebGL,基本都由 CSS transition 和状态 class 完成。

比如购买按钮的 hover 交互包含这些细节:

  • 左侧色条从 scaleX(0) 展开。
  • 文案轻微向右移动。
  • 上下边线各自做 1px 的位移。
  • 右侧图标滑入并切换前景色。

单看每一个动作都很小,但它们的时间曲线和方向一致,所以组合起来会显得“很贵”。

它的动画为什么不乱

很多网站也有 3D、视差和滚动动画,但看起来像一堆效果贴在页面上。Stripe Press 比较聪明的地方,是它把页面交互设计成了几个清晰的状态。

状态一:书籍总览

真实的书籍列表在页面流里正常排列,WebGL canvas 固定在视口中间。用户滚动时,DOM 列表继续提供页面高度和文本内容,canvas 里的书脊和封面则根据滚动位置移动。

这意味着滚动条仍然是真实的,浏览器仍然知道页面有多长,搜索引擎和辅助技术也仍然能读到书名、作者和描述。

状态二:聚焦某一本书

当某本书进入合适的滚动位置,页面控制器会把它设为 active product:

  1. 其他书籍详情隐藏。
  2. 当前书籍从书脊队列里移动到舞台中央。
  3. 左侧菜单切换成当前产品指示器。
  4. 页面前景色和背景色切换为这本书的配色。
  5. URL 通过 History API 更新成对应的书籍路径。

这就是为什么你感觉自己“没有离开首页,却进入了详情页”。本质上它是同一个页面里的状态切换,而不是一次完整的路由跳转。

状态三:鼠标和拖拽

桌面端的鼠标移动会根据当前位置更新封面的 X、Y 旋转。按下鼠标后则进入拖拽状态,松开时保留旋转结果;鼠标悬停在书脊上,还会把书脊从队列中略微抬起。

这里有一个很值得抄的细节:它把鼠标事件和真正的渲染循环分开了。事件只更新目标旋转值,requestAnimationFrame 再把当前值慢慢逼近目标值。

let targetRotation = 0;
let currentRotation = 0;

window.addEventListener('pointermove', (event) => {
  targetRotation = (event.clientX / innerWidth - 0.5) * 0.45;
});

function render() {
  currentRotation += (targetRotation - currentRotation) * 0.12;
  book.rotation.y = currentRotation;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

render();

如果在 pointermove 里直接改 Mesh,再叠加多个组件各自渲染,动画很快就会抖。目标值和渲染值分离,手感会稳定很多。

状态四:电影和播客

电影区域不是把所有内容都塞进书籍场景里,而是切换到单独的 poster、背景和 overlay。播客则有自己的音频预览、展开条目和 transcript overlay。

从代码组织上看,它们是独立 controller 和独立 scene,但会接收页面级的滚动进度和当前产品状态。这种“独立实现,统一编排”的方式,正好避免了一个超级组件越写越大。

最值得借鉴的 6 个做法

1. 用 DOM 保住内容,用 WebGL 放大体验

最重要的一条是不要把整个页面画成一张图片。

更稳妥的分层是:

DOM 内容层
  书名、作者、描述、链接、按钮、正文、可访问文本

CSS 交互层
  菜单、hover、遮罩、颜色、过渡、响应式布局

WebGL 视觉层
  书籍模型、材质、灯光、粒子、相机运动

媒体层
  视频、音频、Canvas 频谱、字幕和 transcript

这样做的好处很现实:WebGL 挂了,内容还在;移动端性能不够,仍然可以显示普通封面;需要 SEO 时,文本也不是藏在 GPU 里。

2. 把滚动看成状态输入,而不是动画触发器

不要写“滚动到 500px 就播放动画 A,滚动到 1000px 就播放动画 B”这种越来越难维护的代码。更好的方式是把滚动映射成 0 到 1 的进度:

const progress = clamp(
  (scrollY - sectionTop) / sectionHeight,
  0,
  1
);

book.position.y = lerp(startY, endY, easeInOut(progress));
camera.position.z = lerp(8, 5.5, progress);
background.style.opacity = String(progress);

同一个 progress 可以同时驱动模型位置、相机距离、文字透明度和背景色。视觉上的连续感,往往就来自这一个统一进度,而不是动画数量多。

3. 用材质参数制造层次,不要堆模型数量

Stripe Press 的书籍舞台看起来信息量很大,但核心对象其实很集中。真正增加质感的是材质参数和光照变化。

自己的项目可以先做三个层级:

  1. 一张 diffuse 贴图,先把物体颜色做对。
  2. 一张 bump 或 normal 贴图,让光线有细节。
  3. 一个简单的 specular 或 foil 参数,给重点区域一点高光。

做到这一步,通常已经比堆十几个没有材质变化的几何体更有效。

4. 动画组件要有明确的生命周期

播客均衡器的实现特别值得看:音频上下文不是页面加载时就启动,只有用户点击播放才初始化;暂停时停止动画循环;Canvas 尺寸变化时重新按 devicePixelRatio 设置绘图尺寸。

任何持续动画都应该回答三个问题:

  • 什么时候开始?
  • 什么时候暂停?
  • 什么时候销毁?

否则页面滚到下面以后,隐藏区域还在 60 FPS 地消耗 CPU 和 GPU。

5. 视觉层要有降级出口

Stripe Press 的页面控制器会检测 WebGL 是否可用。不可用时会给页面加 PressHomepage--isNotWebGL 状态,产品列表恢复显示,详情区域也用普通 CSS 占位。

这个细节比“我用了 WebGL”更重要。你的动画应该是增强,不应该是内容的唯一入口。

可以从最简单的 CSS fallback 开始:

.scene-canvas {
  position: fixed;
  inset: 0;
}

.no-webgl .scene-canvas {
  display: none;
}

.no-webgl .card-cover {
  display: block;
  width: min(70vw, 360px);
  aspect-ratio: 3 / 4;
}

另外别忘了 prefers-reduced-motion。Stripe Press 的主页面代码里我没有看到一个统一关闭全部动效的开关,所以自己的项目最好补上这个保险:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
    transition-duration: 0.01ms !important;
  }
}

6. 键盘入口和真实链接不能丢

页面的书籍入口是 <button>,里面的书名仍然有真实 <a>,控制器也监听了 Enter 键。这个组合说明一件事:3D 交互可以很炫,但用户最终还是要靠按钮、链接和键盘完成任务。

如果你要做类似的卡片舞台,至少保留这些能力:

  • Tab 可以聚焦卡片。
  • Enter 可以打开详情。
  • Escape 可以关闭 overlay。
  • 当前状态有清晰的视觉和语义反馈。
  • WebGL 画面之外存在真实文本。

如果我现在做一个“Stripe Press 风格”的项目

我不会一上来就建一个完整的 3D 书店。更实际的迭代顺序是这样:

第 1 步:先做不用 WebGL 也成立的页面

完成真实的列表、详情、链接、滚动结构和移动端布局。此时即使删掉所有 canvas,页面也应该能用。

第 2 步:只做一个视觉对象

选一张卡片或一张海报,用 Three.js 做一个固定位置的模型。先把相机、尺寸、纹理加载和销毁处理好。

第 3 步:把滚动接到一个统一进度

先只控制模型的 Y 位置和一个背景透明度,确认滚动手感稳定,再加旋转、材质和文字过渡。

第 4 步:加交互状态

定义 listactiveoverlayreducedMotionnoWebGL 这些状态,所有组件只读状态,不要互相偷偷改 class。

第 5 步:最后才加材质和声音

foil、gloss、粒子、音频频谱都属于加分项。它们应该服务于内容节奏,而不是为了证明“我会写 shader”。

一个可以直接复用的检查清单

做完类似页面后,我会逐项检查:

  • 关闭 JavaScript 后,核心文字和链接是否仍然可见?
  • WebGL 创建失败时,是否有普通封面或 CSS 版本?
  • 滚动事件是否只更新目标值,渲染是否统一在 requestAnimationFrame
  • 隐藏区域的动画和音频是否真的暂停?
  • 图片是否按需加载,纹理是否区分首屏和后续内容?
  • Canvas 是否按 devicePixelRatio 控制尺寸,并限制最大像素量?
  • 移动端是否关闭了不必要的拖拽和高成本材质?
  • prefers-reduced-motion 是否有明确行为?
  • 键盘、焦点、Escape 和真实链接是否完整?
  • URL、浏览器后退和直接访问详情路径是否正常?

写在最后

Stripe Press 最值得学的,不是它把首页做成了 3D 书架,而是它没有让 3D 书架绑架整个产品。

表面上看,用户是在拖动一本会反光的书;真正发生的事情,是一个 DOM 内容系统、一个 WebGL 渲染系统、一套 CSS 状态动画和一条媒体播放链路,被页面级状态机编排成了同一个体验。

所以如果你也想做“很炫”的网站,建议先问自己三个问题:

  1. 哪一部分内容必须真实存在于 DOM?
  2. 哪一个视觉对象值得用 GPU 表现?
  3. 当动画失败、设备变慢或用户不想动时,页面还能不能成立?

这三个问题想清楚以后,Three.js 只是工具。真正决定作品质感的,是你有没有把内容、状态和动效放在正确的层里。

参考资料

评论互动

© 2026 王若风的技术博客 · Powered by Astro