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-target、data-js-target-list 互相找到。加载器会读取页面里的 script registry,然后按需动态 import 对应的 controller 模块。
从源码命名可以看到几个重要模块:
PressHomepage:页面级状态、滚动、鼠标、键盘和 URL 状态。PressHomepageCanvas:WebGL canvas、书籍 Mesh、电影海报和播客场景。PressHomepageProductList:真实的书籍、电影和播客列表。PressHomepageMenu:左侧导航和产品指示器。PressHomepageFilmOverlay、PressHomepagePodcastTranscriptOverlay:全屏内容层。
这是一种很朴素、但很有效的架构:控制器只关心自己那一小块 DOM,页面控制器负责跨模块协调。
如果你熟悉 Stimulus、Alpine.js 或一些传统的渐进增强方案,会发现它和“给 HTML 加行为”的路线很接近,只是 Stripe 自己做了模块加载和依赖管理。
2. Three.js 加 WebGL,而不是把页面全画进 Canvas
首页运行时有一个 PressHomepageCanvas,在桌面尺寸下会创建一张全屏 WebGL canvas。页面加载的 v1-Page-4DSXCIGJ.js 又依赖包含 Three.js 的共享 chunk,运行时可以看到 THREE.WebGLRenderer、ShaderMaterial、MeshStandardMaterial 等痕迹。
书籍不是一张平面图片贴在 <img> 上。源码里能看到书封的 3D Mesh、相机、灯光,以及一组材质贴图:
- diffuse:基础颜色。
- bump:凹凸细节。
- foil:烫金或金属箔效果。
- gloss:高光。
- glitter:颗粒或闪烁效果。
也就是说,鼠标移动时你看到的并不只是 transform: rotate(),而是模型旋转、灯光变化和材质反射共同产生的错觉。
源码里还出现了自定义 vertex shader 和 fragment shader。这里可以做一个重要区分:Three.js 是渲染基础设施,真正决定质感的是几何体、纹理、光照和 shader 参数。
3. Canvas 2D 加 Web Audio 做播客均衡器
播客区域用了另一张 Canvas。它不会一开始就初始化音频,而是在用户点击播放后才创建 AudioContext 和 AnalyserNode,再通过 requestAnimationFrame 读取频率数据,把每个频段画成一根红色竖条。
这条链路非常清楚:
用户点击播放
→ AudioContext
→ MediaElementSource
→ AnalyserNode
→ getByteFrequencyData
→ Canvas 2D 频谱柱
这比循环播放一个提前录制好的动画更有意思,因为动画和声音真的同步,而且只有在需要时才创建音频上下文。
4. CSS 仍然承担了大量“高级感”
页面加载了多个按组件拆分的 CSS 文件,字体也分成 Ivar Display、Ivar Headline 和 Ivar Text 三组。菜单、按钮、遮罩、背景颜色、链接 hover、面板滑入,并不需要 WebGL,基本都由 CSS transition 和状态 class 完成。
比如购买按钮的 hover 交互包含这些细节:
- 左侧色条从
scaleX(0)展开。 - 文案轻微向右移动。
- 上下边线各自做 1px 的位移。
- 右侧图标滑入并切换前景色。
单看每一个动作都很小,但它们的时间曲线和方向一致,所以组合起来会显得“很贵”。
它的动画为什么不乱
很多网站也有 3D、视差和滚动动画,但看起来像一堆效果贴在页面上。Stripe Press 比较聪明的地方,是它把页面交互设计成了几个清晰的状态。
状态一:书籍总览
真实的书籍列表在页面流里正常排列,WebGL canvas 固定在视口中间。用户滚动时,DOM 列表继续提供页面高度和文本内容,canvas 里的书脊和封面则根据滚动位置移动。
这意味着滚动条仍然是真实的,浏览器仍然知道页面有多长,搜索引擎和辅助技术也仍然能读到书名、作者和描述。
状态二:聚焦某一本书
当某本书进入合适的滚动位置,页面控制器会把它设为 active product:
- 其他书籍详情隐藏。
- 当前书籍从书脊队列里移动到舞台中央。
- 左侧菜单切换成当前产品指示器。
- 页面前景色和背景色切换为这本书的配色。
- 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 的书籍舞台看起来信息量很大,但核心对象其实很集中。真正增加质感的是材质参数和光照变化。
自己的项目可以先做三个层级:
- 一张 diffuse 贴图,先把物体颜色做对。
- 一张 bump 或 normal 贴图,让光线有细节。
- 一个简单的 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 步:加交互状态
定义 list、active、overlay、reducedMotion、noWebGL 这些状态,所有组件只读状态,不要互相偷偷改 class。
第 5 步:最后才加材质和声音
foil、gloss、粒子、音频频谱都属于加分项。它们应该服务于内容节奏,而不是为了证明“我会写 shader”。
一个可以直接复用的检查清单
做完类似页面后,我会逐项检查:
- 关闭 JavaScript 后,核心文字和链接是否仍然可见?
- WebGL 创建失败时,是否有普通封面或 CSS 版本?
- 滚动事件是否只更新目标值,渲染是否统一在
requestAnimationFrame? - 隐藏区域的动画和音频是否真的暂停?
- 图片是否按需加载,纹理是否区分首屏和后续内容?
- Canvas 是否按
devicePixelRatio控制尺寸,并限制最大像素量? - 移动端是否关闭了不必要的拖拽和高成本材质?
-
prefers-reduced-motion是否有明确行为? - 键盘、焦点、Escape 和真实链接是否完整?
- URL、浏览器后退和直接访问详情路径是否正常?
写在最后
Stripe Press 最值得学的,不是它把首页做成了 3D 书架,而是它没有让 3D 书架绑架整个产品。
表面上看,用户是在拖动一本会反光的书;真正发生的事情,是一个 DOM 内容系统、一个 WebGL 渲染系统、一套 CSS 状态动画和一条媒体播放链路,被页面级状态机编排成了同一个体验。
所以如果你也想做“很炫”的网站,建议先问自己三个问题:
- 哪一部分内容必须真实存在于 DOM?
- 哪一个视觉对象值得用 GPU 表现?
- 当动画失败、设备变慢或用户不想动时,页面还能不能成立?
这三个问题想清楚以后,Three.js 只是工具。真正决定作品质感的,是你有没有把内容、状态和动效放在正确的层里。
评论互动