本站是一家生产派对礼服与舞台服装的公司的官方网站。由于经营的是“站在舞台上的人的服装”,我们将首页设想成一座剧场,重新构建为如下结构:首屏是闭合的大幕(緞帳),随着滚动,幕布缓缓升起,露出舞台之上的业务内容。本文梳理这一开幕演出的实现方针,以及中途遇到的 position: sticky 规范。关于 CSS 的行为,我们以 MDN 的官方文档为依据进行引用。
想要实现的演出
需求有以下三点。
- 首屏是闭合的幕布。随滚动只有幕布升起,在此期间页面不前进(希望幕后的舞台在原位显现出来)。
- 幕布完全升起后,恢复通常的滚动,流向下一节。
- 不夺取滚动操作本身。不做以
preventDefault()接管滚轮的所谓滚动劫持。
构成 — sticky 与滚动余量的“定式”
这类滚动联动演出(scrollytelling,滚动叙事)有一套定式:用 position: sticky 将演出对象固定在画面上,并让其父元素拥有“用于演出的滚动距离”那么高的高度。用户只是在正常地滚动页面,但在滚动经过父元素的过程中,演出对象保持贴在画面上不动,只有滚动量作为演出的进度被消耗。越过父元素后固定自然解除,恢复通常的滚动。
<div data-curtain-track>
<!-- 剧场(舞台+大幕)。贴在页眉正下方 -->
<div class="sticky" style="top: var(--header-h)">
<section class="stage">
…舞台(业务内容卡片、地板、照明)…
<div data-curtain>…大幕(酒红色的幕布)…</div>
</section>
</div>
<!-- 用于拉开幕布的滚动余量 -->
<div style="height: 190svh"></div>
</div>进度由“轨道被滚动经过了多少”来计算。最初的 30svh(画面高度的三成)设为幕布不动的“蓄势”,从此处起用 160svh 完全拉开。像开幕这种一次性的演出,与其开始得太早,稍作牵引更有舞台的感觉。
// 0~1 的进度。最初 30svh 是蓄势,从此处起用 160svh 拉开
const progress = () => {
const gone = headerH - track.getBoundingClientRect().top
return Math.max(0, Math.min(1, (gone - innerHeight * 0.3) / (innerHeight * 1.6)))
}幕布的移动并不与滚动位置直接挂钩,而是插入每帧以一定比例向目标值靠近的插值(lerp)。即便急促滚动,幕布也会柔和地延迟跟随,呈现出厚重布料的惯性。
current += (target - current) * 0.16
curtain.style.transform = `translateY(${-current * (curtain.offsetHeight + hemH)}px)`此外,自行拉开了一部分的用户,可以判断为“想看后续”,因此一旦检测到进度超过 1/3,剩余部分便用 scrollTo({ behavior: 'smooth' }) 自动送至全开位置。仅在前进方向触发,若重新合上幕布则再次生效。
卡住之处 — 用 padding 无法让 sticky 贴住
起初,滚动余量是用父元素的 padding-bottom: 190svh 来确保的。然而这样一来,sticky 完全不贴住。明明 position: sticky 与 top 作为计算值都被正确应用,元素却随滚动一同流走。
sticky 失效时的常见原因——祖先的 overflow: hidden、祖先的 transform、top 的漏写——我们逐一确认,均不符合。进一步细分后发现,在同一父元素中,高度较小的测试元素能毫无问题地贴住。不贴住的,只有占据父元素全部内容的高个元素。
原因在于 containing block(包含块)的定义。MDN 关于 position 的说明中,sticky 元素是这样描述的。
It’s treated as relatively positioned until its containing block crosses a specified threshold (such as setting top to value other than auto) within its flow root (or the container it scrolls within), at which point it is treated as “stuck” until meeting the opposite edge of its containing block.
(被视为相对定位,但当其 containing block 在其流根(或其所滚动的容器)内越过指定阈值——例如 top 被设为 auto 以外的值——时,便被视为"固定",并持续到抵达其 containing block 的对侧边缘为止。)
也就是说,sticky 元素能够移动的范围由 containing block 决定。而 containing block 的定义,MDN 的说明中如下所述。
If the position property is static, relative, or sticky, the containing block is formed by the edge of the content box of the nearest ancestor element that is either a block container (…) or establishes a formatting context (…).
(当 position 属性为 static、relative 或 sticky 时,containing block 由最近的、作为块容器(或建立格式化上下文)的祖先元素的内容框边缘构成。)
关键在于“内容框的边缘“。与 absolute 的 containing block 由 padding 框构成不同,sticky 的可动范围不包含父元素的 padding。在本次的构成中,sticky 元素(剧场)自身即父元素内容的全部,内容框的高度=自身的高度。可动范围的余量为零,所以连 1px 都无法贴住。高度较小的测试元素之所以能贴住,是因为内容框之中尚有余量,可用同样的道理来解释。
解决办法很简单:不用 padding,而用实体元素来确保滚动余量。
<div data-curtain-track>
<div class="sticky" style="top: var(--header-h)">…剧场…</div>
<div style="height: 190svh"></div> <!-- 不是 padding,而是实体的占位元素 -->
</div>由于占位元素被包含在父元素的内容框中,sticky 元素便能贴住那么高的一段距离。
对希望减少动效者、以及 JS 被禁用环境的照顾
大型元素随滚动联动而移动的演出,对有前庭系统障碍的人可能成为不适的诱因。MDN 关于 prefers-reduced-motion 的说明中也明确指出,该设置是用户“希望将非本质的动效降至最低”的意思表示。本次我们准备了如下回退方案。
- 在
prefers-reduced-motion: reduce的环境中,停止幕布的大幅移动,改以随进度而变的透明度渐变来表现开合(自动滚动也不触发)。 - 在 JS 被禁用的环境中,以
<noscript>的样式移除幕布与占位元素,从一开始就以通常的滚动展示已开启的舞台。 - 完全升起、已不可见的幕布之上的按钮等,设为
inert,将其从焦点与点击的对象中排除。
只用 CSS 就能写的日子不远了
顺带一提,滚动联动动画正作为 CSS 的 scroll-driven animations(animation-timeline: scroll())推进标准化,本来是想用它来写的。不过 MDN 的参考文档中明确写着,截至 2026 年 7 月为“Limited availability — This feature is not Baseline because it does not work in some of the most widely-used browsers”(提供状况有限——因为在部分被广泛使用的浏览器中无法运作,故非 Baseline)。由于需要在所有浏览器中一致地运作,以及随进度进行 JS 侧的控制(lerp、自动滚动、inert 的切换),本次采用了原生 JavaScript(requestAnimationFrame 与 IntersectionObserver)来实现。
小结
- 在首页实现了“用 sticky 固定剧场,随滚动余量的长度拉开大幕”的演出。不夺取滚动操作。
- sticky 的可动范围由 containing block 决定,而 containing block 由父元素内容框的边缘构成。padding 不会成为滚动余量——滚动余量要用实体元素来确保。
- 为进度加入了“蓄势”与 lerp 的惯性,一旦用户自行拉开到 1/3,剩余部分便自动送达。
- 在
prefers-reduced-motion下替换为渐变,JS 被禁用时回退为从一开始就已开启的状态。 - 由于 CSS 的 scroll-driven animations 截至 2026 年 7 月尚非 Baseline,本次采用 JavaScript 实现。
参考资料
- position - CSS | MDN — sticky 的行为与 containing block 的关系
- Layout and the containing block - CSS | MDN — 各 position 值对应的 containing block 的定义
- prefers-reduced-motion - CSS | MDN — 减少动效设置的检测与照顾的思路
- scroll() - CSS | MDN — scroll-driven animations 与提供状况
