关于 horizontal-scroll-scenes
用于构建横向章节滚动场景的技能,所有视觉状态均由原生滚动位置推导。适合制作滚动叙事的开发者,涵盖可逆导航、场景切换、无障碍与生命周期处理。
主要功能
- 01根据归一化滚动位置推导场景状态
- 02沿横向世界轴排列章节
- 03通过擦除或平移切换场景
- 04支持反向滚动、跳转与重新加载
- 05处理减少动态效果及屏幕阅读器访问
适用场景
- 01制作滚动驱动的章节叙事
- 02确保锚点跳转后场景状态一致
- 03设计离散视觉场景之间的过渡
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mengto/ skills --skill horizontal-scroll-scenes安装后在 Claude Code 中输入/horizontal-scroll-scenes确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
demo.htmlPROMPT.mdagents/openai.yaml详细介绍
水平滚动场景
运作机制: 滚动位置是唯一的状态。每一帧都会由一个纯函数将 scrollY / (scrollHeight - innerHeight) 转换为 {scene, dwell 0→1, move 0→1},屏幕上的所有内容(摄像机、擦除线、工艺时钟、文本、索引)都根据这三个值绘制。没有任何状态会记住你之前的位置,因此每个节拍都是页面上的一个位置。
这就是 Kibori 的做法,它不是一个 translateX 滑块。运行验证结果:页面是一个 1742vh 的原生滚动轨道(在 1440×900 下为 15,678 px);一个固定舞台容纳了一个 WebGL 世界,其中的摄像机从左向右穿过八个站点,行进距离为 0 → 49.7 个世界单位;在站点之间,一条不规则的灼烧线会从即将离开的场景冻结帧擦除到已经在下一站构好画面的实时场景。没有滚轮处理器、没有吸附,也没有存储的“当前章节”。在滚动到一定深度时重新加载、拖动滚动条和跳转锚点都能正常工作,因为状态是推导出来的,而不是累积出来的。
故事和 3D 世界方面,可使用 scroll-world-storytelling 或 build-threejs-scroll-worlds;固定粘性区块可使用 gsap-scrolltrigger-storytelling;进度线可使用 scroll-progress-timeline。当交付内容需要沿 X 轴排列、每个离散章节都带有一个停留节拍,而难点在于让状态机经受反向滚动、跳过和刷新时,就该使用这个方案。演示使用 Vanilla JavaScript + DOM/CSS + inline SVG;源码使用 three.js。状态机完全相同。
节奏:两种权重和轨道高度
所有数值都在 DWELL、MOVE 和轨道高度中定义。一个单位代表一屏滚动。
| 常量 | 值 | 含义 |
|---|---|---|
DWELL | 1.15 | 在一个场景内滚动的距离,同时场景中的工艺继续推进(在 900 像素高的屏幕上约为 1,030 px) |
MOVE | 0.85 | 滚动到下一个场景的距离(约为 760 px);最后一个场景没有这段距离 |
CYC | 2.00 | 每两屏一个场景 |
SPAN | N*DWELL + (N-1)*MOVE | 8 个场景:15.15,加上 1.35 的页脚,共 16.5;演示版有 5 个场景:9.15 |
| 轨道 | (SPAN + 1) * 100vh | 因此 1 个单位约等于 1 屏;Kibori 的轨道高度为 1742vh,对应 16.5 |
function travel(sp){ // sp = scrollY / (scrollHeight - innerHeight)
const u = Math.min(SPAN - 1e-5, sp*SPAN);
const i = Math.min(N - 1, Math.floor(u/CYC));
const local = u - i*CYC;
const dw = Math.min(1, local/DWELL); // the scene's own clock
const f = i >= N-1 ? 0 : clamp01((local - DWELL)/MOVE); // the move
const rc = clamp01((f - 0.01)/0.97); // the line's own clock
return {i, u, dw, f, fe: sstep(0,1,f), L: -0.08 + 1.24*rc, // L: line, off-frame at both ends
live: Math.min(N-1, f < 0.39 ? i : i+1)}; // what the index shows
}
使用 scrollHeight - innerHeight 进行归一化,绝不要使用硬编码的像素高度:手机上收起的 URL 栏会在滚动过程中改变 innerHeight,而基于像素的映射会逐渐偏移。
规则及其分别防止的问题
- 推导状态,不要累积。 切勿保存
currentScene或累加滚轮增量。滚动条拖动、Home/End、锚点跳转、前进后退和刷新都会导致存储状态不同步。验证方法:正向经过 20 个u值时,记录每个可见样式的签名;再反向记录一次,然后逐个直接跳转到这些值,最后在三个深度调用reload()后再记录。所有签名都必须相同(演示运行结果:21 个中 0 个不匹配、0 次跳过、0 次刷新不匹配)。 - 不要劫持滚轮,也不要用
scroll-snap。 劫持会破坏触控、触控板惯性滚动、Space/PageDown和页面内查找。滚动吸附会与停留阶段冲突,而停留阶段本身就是静止平台(1.15 个屏幕高度内只有飞行器移动)。保持原生 1:1 滚动:滚轮滚动 2,000 px,得到的正好是u = 2000/(scrollHeight - vh)*SPAN。 - 在停留阶段之外固定飞行器时钟。 场景
k的进度为:k < i时为1,k == i时为dw,之后为0。这样身后的场景就不会突然跳回,反向滚动也能精确重现。将其写为舞台上的--p{k}和缓动后的--e{k}自定义属性;两个世界副本都读取这些属性,因此每帧只需写入一次。 - 没有任何移动的移动,就是无效滚动。 让线几乎一开始就从画面边缘起步(
rc = (f-0.01)/0.97);如果起点放得更远,每次移动的前八分之一都会毫无变化。 - 擦除线要遮住镜头。 让离场画面定格在线前方,同时让实时世界已在其后抵达目的地。若镜头仍在线下移动,画面会像双重曝光。演示:图层 A 的镜头位于
i,裁剪到线的远侧;图层 B 位于i+1,裁剪到线的近侧。不需要擦除效果时,可改用平移模式(translateX(-(i+fe)/N*100%))。 - 擦除线写入下一章。 根据入场文字所在的一侧交替改变扫过方向。离场文字使用遮罩
linear-gradient(a, transparent (L-.01)%, #000 (L+.016)%),入场文字使用#000 (L-.062)%, transparent (L-.048)%,其中a = 90deg或270deg。两个章节不会同时占据画面同一区域。在手机上,两者位于同一个位置:在线扫过后才显示入场内容,使用sstep(1.00, 1.14, L)。 - 在
f < 0.39时切换索引,而不是 0.5。 到达中点时,入场标题已经显示了一半;若切换太晚,索引就会标错场景。将非活动行淡化到0.55,而不是0。 - 章节时钟要有意重叠。 入场从场景归位时间前
MOVE*0.80开始,并在其后DWELL*0.62结束;离场从DWELL + MOVE*0.28开始,耗时MOVE*0.34。各部分(眉题、标题、导语、数字)分别占用arrive的一段时间(0、.12、.40、.60,外加 0.30 的渐变时长)。 - 快速跳过时只播放最后一次移动。 若目标相隔一个以上场景:用 380 ms 淡入烟雾,调用
scrollTo移至目标前的停靠点(正向为(tgt-1)*CYC + DWELL*0.96,反向为(tgt+1)*CYC + 0.02),然后用 2,600 ms 播放最后一次移动,并在前 24% 的过程中淡去遮罩,缓动公式为1-(1-x)^2.4。隐藏跳过的场景,否则其文字会闪现。停在tgt*CYC + DWELL*0.80,让目标场景完整呈现。相隔一个场景或更少时:使用简单的移动段,时长为clamp(700 + |dy|/vh*150, 900, 2400)ms,采用三次缓入缓出。否则,从 1 跳到 8 时会闪现六团火焰。 - 任何滚轮、触控或导航按键操作都会取消滑动动画。 否则页面会与用户的操作对抗。
html{scroll-behavior:smooth}会破坏滑动动画和每次截图。 逐帧调用scrollTo会不断追逐自身;Playwright 截图会在滚动过程中截取,看起来像是重叠错误。请设为auto。- 隐藏章节时同时设置
visibility:hidden和inert。 仅设置opacity: 0仍会让不可见链接可聚焦,也会被屏幕阅读器读取。 - 在
scroll、resize、pageshow、load和fonts.ready时渲染。 刷新会在首次绘制后恢复滚动位置;从 bfcache 恢复时不会触发滚动事件。对零尺寸舞台进行保护,并使用ResizeObserver获取尺寸。 - 重复的世界副本必须使用唯一的 SVG id。 如果两个副本共用一个
clipPathid,url(#id)只会解析到第一个副本。 - 只播报已提交的变更。 当滑动动画结束时,使用礼貌型实时区域播报一次“第 4 个场景,共 5 个:Yakisugi”,而不是每经过一个阈值都播报。
无障碍与生命周期
- 索引行是真实链接,带有
aria-current="step"和一个偏移 5 px、可见的 1 px 轮廓;品牌链接返回场景 1。舞台使用position:fixed,置于纯高度轨道上方,因此Space、方向键、PageDown、Home和End都能原生滚动。 prefers-reduced-motion: reduce:不绘制线条,不进行位移。场景 B 在f为 0.30 → 0.70 时交叉淡入淡出;传入章节等到f = 0.64才显示;滑动立即执行scrollTo;工艺仍随滚动推进(这是用户自己的输入)。控件始终可用。document.hidden:在目标位置结束任何滑动,避免标签页恢复后计入暂停期间的时间。- 移动端:索引只显示当前行(
03 / 05 Kigumi),章节置于底部,作品置于其上方。
成本,实测
状态机没有开销:在 400 步扫描中,render() 的耗时处于 0.1 ms 计时器下限(p50 为 0,p90 为 0.1);演示中的 2.6 s 滑动期间,p50 为 8.3 ms(120 Hz 上限),最大值为 9.2 ms。成本取决于场景的绘制开销。在 Kibori 中,停留帧比过渡帧还慢,直到 4096² 阴影贴图不再每帧重绘,随后再用剪裁处理实时场景;参见 kibori-60fps 说明。切勿花精力优化 travel()。
故障特征
| 你看到的现象 | 原因 |
|---|---|
| 重新加载后回到场景 1 | 状态保存在 JS 中;应从 scrollY 推导,并在 load/pageshow 时渲染 |
| 场景在两个节拍之间“卡住” | 启用了吸附或拦截滚轮;删除它 |
| 两个房间在线条下方重叠 | 镜头仍在遮罩下方移动 |
| 标题落定时索引仍显示旧场景 | 翻转阈值设为 0.5,而不是 0.39 |
| 跳转链接连续闪过多个场景 | 每次移动都会播放滑动,而不是只播放最后一次 |
| 跳转链接越过目标或卡顿 | html 上设置了 scroll-behavior:smooth |
| 第二个场景的剪辑不起作用 | SVG id 重复 |
| 屏幕阅读器读出不在屏幕上的章节 | 只设置了 opacity 为 0,没有设置 inert |
| 手机上两个标题在线条处拼接 | 传入章节没有等到线条移开后才显示 |
来源
摘自 KIBORI 木彫,一个以单个 three.js 文件构建的京都木工坊页面,展示八种工艺(kibori.html、travel() 以及其模块脚本末尾附近的滑动代码块)。演示在该页面的暗色与余烬风格中呈现,包含五种手绘 SVG 工艺。