滚动交互这件事,看起来是最简单的浏览器行为之一——用户手指一划、滚轮一转,页面就动了。但真要在项目里做出"沉浸感",你会发现原生滚动能给你的控制权少得可怜:你没法知道滚动到底是在惯性滑行还是用户主动拖拽,没法在滚动过程中插入自定义的阻尼曲线,更没法让滚动位置和动画时间轴精确对齐。我最初接触 Houdini 的 Scroll Customization API,就是因为一个滚动驱动的视觉项目卡在了"手感"上——动画逻辑都对,但滚起来就是"塑料感",像在拖一张贴图而不是在推动一个有重量的东西。
这篇内容聊的就是怎么用 CSS Houdini 里与滚动定制相关的能力,把滚动的"手感"从浏览器默认行为里夺回来。需要先说明一点:Houdini 的滚动定制并不是一个已经全部落地、开箱即用的完整规范,它更像一组正在推进的底层能力集合,其中一部分(比如scroll-timeline、view-timeline这类滚动驱动动画的接口)已经在主流浏览器里有可用实现,另一部分(比如更底层的滚动输入接管、自定义滚动物理)仍处于提案或实验阶段。所以本文的定位是:把已经能用的部分讲透、把原理讲清、把还在路上的部分讲明白它的设计意图,让你在当下能落地,也能判断未来该往哪个方向押注。
适合谁看:做过滚动动画但被"手感"折磨过的前端;想用纯 CSS 方案替代一部分 JS 滚动监听的人;以及想理解浏览器滚动机制底层逻辑、为后续技术选型做判断的开发者。下面从原生滚动的能力边界讲起,一路拆到可复现的实现方案和踩坑记录。
1. 原生滚动为什么做不出"沉浸感"
1.1 滚动事件的先天缺陷:它只告诉你"变了",不告诉你"怎么变的"
绝大多数人做滚动交互的第一反应是监听scroll事件,读scrollTop,然后算比例、改样式。这条路能跑通,但它有一个根本性的信息缺失:scroll事件只告诉你滚动位置发生了变化,却不告诉你这个变化是用户手指拖出来的、滚轮一格一格滚出来的、还是惯性衰减滑出来的。而"沉浸感"恰恰藏在这个区分里。
举个具体的例子。你想做一个"滚动到某区域时元素带一点延迟跟随"的效果,模拟物体有质量。如果用户是主动拖拽,延迟应该小、响应要跟手;如果用户已经松手、处于惯性滑行阶段,延迟可以大一点、让它自然漂移。但scroll事件给你的信息里没有这个维度,你只能靠时间差去猜——比如两次事件间隔小于 16ms 就认为是连续滚动,大于某个阈值就认为是惯性。这种猜测在桌面端勉强能用,到了移动端触屏惯性滚动上就经常误判,因为移动端的惯性衰减曲线和事件派发节奏跟桌面完全不同。
更麻烦的是性能。scroll事件在高频触发时,如果你在回调里做布局读取(比如getBoundingClientRect)再写样式,很容易触发强制同步布局,滚动一卡一卡的。虽然可以用passive: true缓解一部分,但那只解决了"不阻塞滚动"的问题,没解决"信息不够"的问题。
1.2 滚动条本身几乎不可定制,这是沉浸感的第一道墙
沉浸式体验的一个隐含要求是:滚动条不该抢戏。但原生滚动条的样式能力非常有限,::-webkit-scrollbar系列伪元素只在部分内核里有效,Firefox 用的是scrollbar-width和scrollbar-color这套完全不同的属性,两边能表达的东西对不上。你想做一个"滚动条随滚动渐隐、只在滚动时短暂出现"的效果,纯 CSS 基本做不到,还是得回到 JS 监听。
而 Houdini 的思路不是"给你更多伪元素选择器",而是"让你用底层 API 自己画一个滚动指示器"。这就引出了后面要讲的CSS.paintWorklet配合滚动状态的能力——你可以把滚动进度作为输入,用 Canvas 2D 在 worklet 里绘制任意形态的滚动指示器,性能还比 JS 主线程方案好,因为它跑在独立的渲染线程上。
1.3 滚动与动画时间轴的错位:两套时钟各走各的
这是最隐蔽也最影响手感的一点。CSS 动画和过渡用的是文档时间轴(document timeline),从页面加载开始计时;而滚动位置是空间量,跟时间没关系。你想让"滚动到 50% 时动画播到一半",就得手动把空间量映射成时间量,这个映射在 JS 里做就是每帧读scrollTop再设animation-delay或改currentTime,既繁琐又容易掉帧。
滚动驱动动画(scroll-driven animations)要解决的就是这个错位:它让动画的进度直接由滚动位置驱动,而不是由时间驱动。这背后是浏览器把"滚动容器"抽象成了一条时间轴(timeline),动画挂在这条轴上,滚动到哪动画就到哪,天然同步,不需要 JS 参与。理解这一点,是理解整个 Scroll Customization 体系的关键。
2. 滚动驱动动画的两条时间轴:scroll 与 view 的分工
2.1 scroll-timeline:以"容器滚了多远"为进度
scroll-timeline的核心是把一个可滚动容器的滚动进度映射成 0% 到 100%。它的进度计算基准是容器的可滚动范围:scrollTop从 0 到scrollHeight - clientHeight,这个区间对应动画的 0% 到 100%。注意这里有个容易搞错的点——进度不是按"滚了多少像素"算的,而是按"滚了可滚动范围的多大比例"算的。所以同一个动画挂在不同高度的容器上,滚动手感会不一样,容器内容越长,同样的滚动距离对应的进度变化越慢。
用法上分两步:先在滚动容器上声明一条具名时间轴,再在动画元素上引用它。
/* 滚动容器:声明时间轴,名字自定义 */ .scroll-container { overflow-y: scroll; height: 100vh; scroll-timeline-name: --page-scroll; scroll-timeline-axis: block; } /* 动画元素:挂到这条时间轴上 */ .progress-bar { animation: grow linear; animation-timeline: --page-scroll; } @keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }这里scroll-timeline-axis: block指的是"块轴方向",在横排文字里就是垂直方向。用block而不是写死y,是为了适配不同书写模式,这个细节在做国际化项目时很关键——阿拉伯语等从右往左的书写模式下,逻辑轴和物理轴是错开的。
animation-timeline一旦生效,animation-duration就失效了,因为动画进度不再由时间决定。这一点很多人第一次用会懵:明明写了duration: 2s怎么没反应?因为时间轴换了,时间参数被忽略是符合预期的。
2.2 view-timeline:以"元素进入视口的过程"为进度
scroll-timeline的问题是它绑定的是整个容器,你没法针对"某个特定元素滚到眼前"来做动画。view-timeline补的就是这块:它把某个元素相对于视口的可见过程作为进度来源。元素从刚进入视口到完全离开,这个过程被切成 0% 到 100%,你可以精确控制动画在元素"进入多少"时开始、"离开多少"时结束。
.card { view-timeline-name: --card-reveal; view-timeline-axis: block; animation: fade-up linear both; animation-timeline: --card-reveal; /* 关键:控制动画在元素可见过程的哪个区间生效 */ animation-range: entry 0% entry 100%; } @keyframes fade-up { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } }animation-range是这套体系里最有表达力的部分。entry指元素正在进入视口的那段,exit指正在离开的那段,cover指从进入到离开的全过程,contain指元素完全在视口内的那段。你可以组合出"进入时淡入、离开时淡出、完全可见时保持"这种分段效果,而且全程零 JS。
2.3 两条时间轴怎么选:一个判断标准
我给一个简单的判断口径:动画的进度该由"页面整体滚到哪"决定,还是由"这个元素自己露出来多少"决定。前者用scroll-timeline,典型场景是顶部进度条、随页面滚动变化的背景色、整页视差;后者用view-timeline,典型场景是列表项逐个浮现、卡片进入时的位移、图片的视差揭示。
两者也可以叠加用。比如一个页面既有全局进度条(scroll-timeline),又有每个卡片自己的入场动画(view-timeline),互不干扰,因为时间轴是各自独立的命名空间。
3. 用 Paint Worklet 自绘滚动指示器:把滚动条从"系统控件"变成"你的画布"
3.1 为什么不用 JS 画滚动条
先说清楚为什么这条路值得走。用 JS 监听scroll然后改一个div的高度来模拟滚动条,问题有三个:一是主线程繁忙时滚动条会滞后,视觉上"跟不上手";二是每次滚动都要读写 DOM,容易触发重排;三是移动端惯性滚动时事件派发不均匀,滚动条会一跳一跳。
Paint Worklet 跑在渲染线程的独立 worklet 里,它接收的是浏览器已经算好的输入属性(包括滚动进度这类值),绘制结果直接进合成,不经过主线程。这意味着即使主线程在跑重活,滚动指示器依然平滑。这是它相对 JS 方案最本质的优势,不是"写法更优雅",而是"执行位置更对"。
3.2 注册一个滚动指示器 worklet 的完整流程
第一步,写 worklet 文件。它必须是一个独立的 JS 文件,通过CSS.paintWorklet.addModule()加载。
// scroll-indicator-worklet.js class ScrollIndicator { static get inputProperties() { return ['--scroll-progress', '--indicator-color']; } paint(ctx, size, props) { const progress = props.get('--scroll-progress').value || 0; const color = props.get('--indicator-color').toString().trim() || '#4a90d9'; const trackWidth = 4; const thumbHeight = Math.max(size.height * 0.15, 24); const travel = size.height - thumbHeight; const thumbY = travel * Math.min(Math.max(progress, 0), 1); // 轨道 ctx.fillStyle = 'rgba(0,0,0,0.08)'; ctx.fillRect(size.width - trackWidth, 0, trackWidth, size.height); // 滑块 ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(size.width - trackWidth, thumbY, trackWidth, thumbHeight, trackWidth / 2); ctx.fill(); } } registerPaint('scroll-indicator', ScrollIndicator);第二步,在主样式里注册并应用。
/* 注册 worklet */ /* 注意:addModule 必须在 JS 里调用,CSS 只负责引用 */if ('paintWorklet' in CSS) { CSS.paintWorklet.addModule('/scroll-indicator-worklet.js'); }.indicator { position: fixed; top: 0; right: 0; width: 8px; height: 100vh; background: paint(scroll-indicator); /* 滚动进度通过自定义属性喂进去 */ --scroll-progress: 0; --indicator-color: #4a90d9; }第三步,把滚动进度喂给--scroll-progress。这里有个技巧:不要用 JS 每帧去设这个属性,而是用scroll-timeline驱动一个不可见的动画来改它,让浏览器自己算。
@property --scroll-progress { syntax: '<number>'; initial-value: 0; inherits: false; } .indicator { animation: track-progress linear; animation-timeline: --page-scroll; } @keyframes track-progress { from { --scroll-progress: 0; } to { --scroll-progress: 1; } }@property声明是必须的,因为自定义属性默认是字符串类型,worklet 里props.get()拿到的值需要是数值才能直接参与计算。用syntax: '<number>'声明后,浏览器会把它当数字处理,动画插值也才生效。这个坑我踩过——不声明@property直接动画一个自定义属性,浏览器根本不会插值,属性值会从 0 直接跳到 1。
3.3 让指示器"会呼吸":滚动时出现、静止时淡出
沉浸感的一个细节是:滚动条不该一直杵在那。理想状态是滚动时浮现、停下后几秒淡出。纯 CSS 能做到吗?可以,但要借助一个技巧——用scroll-timeline驱动一个"活跃度"属性,再配合transition做淡出。
思路是:滚动进行中,进度值在变;滚动停止,进度值不变。但 CSS 没法直接检测"值有没有在变"。可行的做法是用 JS 做一个轻量的"滚动活跃检测",只设一个布尔类名,不碰布局:
let timer; const indicator = document.querySelector('.indicator'); window.addEventListener('scroll', () => { indicator.classList.add('is-active'); clearTimeout(timer); timer = setTimeout(() => indicator.classList.remove('is-active'), 1200); }, { passive: true });.indicator { opacity: 0; transition: opacity 0.4s ease; } .indicator.is-active { opacity: 1; transition-duration: 0.15s; }这个方案里 JS 只做一件事——切类名,不读scrollTop、不写样式值,开销极小,不会拖累滚动性能。绘制和进度计算全在 worklet 和 CSS 时间轴里完成。这种"JS 管状态、CSS 管表现、worklet 管绘制"的分工,是我目前认为最稳的组合。
4. 阻尼与惯性:自定义滚动物理到底能走多远
4.1 原生滚动没有"阻尼曲线"这个参数
很多人想要"滚动带一点阻尼",本质是想要一个非线性的位置映射:手指移动 100px,内容移动 80px,且这个比例随速度变化。原生滚动不提供这个。overscroll-behavior只能控制边界回弹行为,scroll-behavior: smooth只能让锚点跳转变平滑,都不是阻尼。
在 Houdini 的完整愿景里,滚动输入接管(也就是能拿到原始滚动输入、自己决定内容位移)是更底层的能力,但它涉及安全和可访问性的复杂权衡——如果页面能完全接管滚动,就可能做出"滚动劫持"这种让用户抓狂的体验,所以这块的标准化推进非常谨慎。当下能落地的"伪阻尼"方案,还是回到 JS 拦截 + transform 的路子,但要做得聪明。
4.2 一个可落地的"轻阻尼"实现
核心思路:不让容器真的滚动,而是监听滚轮/触摸,把累积的位移经过一个缓动函数后应用到内容层的transform: translateY()上。这样位置完全由你控制,阻尼曲线随便调。
const content = document.querySelector('.content'); let target = 0; // 目标位移 let current = 0; // 当前位移 const ease = 0.12; // 阻尼系数,越小越"重" function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } function getMaxScroll() { return content.scrollHeight - window.innerHeight; } window.addEventListener('wheel', (e) => { target = clamp(target + e.deltaY, 0, getMaxScroll()); }, { passive: true }); function raf() { current += (target - current) * ease; content.style.transform = `translate3d(0, ${-current}px, 0)`; requestAnimationFrame(raf); } raf();这段代码的关键在ease这个系数。它做的是指数平滑:每一帧当前值向目标值靠近固定比例。ease越小,跟随越慢、越"重";越大越跟手。0.1 到 0.15 之间是比较舒服的区间,低于 0.08 会有明显的"拖泥带水"感,高于 0.2 就接近原生了、失去意义。
但这条路有几个必须处理的坑,我在项目里全踩过:
- 移动端触摸事件和滚轮事件模型不同。
wheel在触屏上不触发,得用touchstart/touchmove自己算位移,还要处理多指、快速滑动的手势识别,复杂度陡增。 - 可访问性。内容层用
transform位移后,键盘的 PageDown、Home/End、屏幕阅读器的跳转全部失效,因为浏览器认为页面根本没滚动。必须手动补键盘事件处理,并保证焦点元素滚入视野时同步target。 - 锚点链接和浏览器前进后退的滚动位置恢复会错乱,因为真实滚动位置始终是 0。
position: sticky和IntersectionObserver全部失灵,因为它们依赖真实滚动。
所以我的结论是:轻阻尼适合"整页视觉展示型"页面,不适合内容型、需要正常阅读和导航的页面。判断标准很简单——如果这个页面用户需要复制文字、用 Ctrl+F 查找、或者用键盘导航,就别上这套。这是选型上的硬边界,不是技术能不能实现的问题。
4.3 用 CSS 变量把阻尼参数暴露出来
如果确定要用,建议把阻尼系数做成 CSS 变量,方便不同区块调不同手感,也方便调试时实时改:
:root { --scroll-ease: 0.12; }const ease = parseFloat( getComputedStyle(document.documentElement).getPropertyValue('--scroll-ease') ) || 0.12;这样设计师可以在 DevTools 里直接改变量看效果,不用改代码重编译。这个小小的工程习惯,在调手感这种"只可意会"的环节里能省大量来回沟通。
5. 兼容性、降级与实测踩坑记录
5.1 当下各能力的落地状态
这块必须说清楚,否则容易照着写完发现跑不起来。截至我最近一次实测,滚动驱动动画(scroll-timeline、view-timeline、animation-range)在主流现代浏览器的新版本里已有可用实现,但旧版本和部分内核仍需前缀或特性检测。Paint Worklet 的支持面更广一些,但同样要做'paintWorklet' in CSS的检测。而"滚动输入完全接管"这类底层能力,目前没有可直接用于生产的标准接口。
| 能力 | 落地状态 | 生产可用性判断 |
|---|---|---|
scroll-timeline/view-timeline | 现代浏览器新版本可用 | 可用,需特性检测 + 降级 |
animation-range | 同上 | 可用,注意区间语法兼容 |
| Paint Worklet 自绘 | 支持面较广 | 可用,需检测CSS.paintWorklet |
@property类型声明 | 支持良好 | 可用,是自定义属性动画的前提 |
| 滚动输入接管 / 自定义物理 | 无标准生产接口 | 不可依赖,JS 模拟为主 |
5.2 降级策略:让不支持的环境"体面地退场"
降级不是"不支持就白屏",而是"不支持时退回到一个可接受的默认体验"。我的做法是三层:
第一层,用@supports检测时间轴能力,不支持时给动画一个基于时间的默认时长,让它至少能动:
.progress-bar { animation: grow 1s linear both; } @supports (animation-timeline: scroll()) { .progress-bar { animation-timeline: --page-scroll; animation-duration: auto; } }第二层,Paint Worklet 不支持时,用原生滚动条兜底,通过scrollbar-width: thin和scrollbar-color给一个协调的配色,至少不突兀。
第三层,JS 阻尼方案在不支持requestAnimationFrame的极端环境(基本只剩很老的设备)下,直接放弃 transform 方案,让容器正常滚动。这个判断用特性检测做,不要用 UA 判断。
5.3 几个实测中反复出现的问题
问题一:animation-range的区间写反了导致动画不触发。entry 0% entry 100%表示元素从刚开始进入到完全进入。如果写成entry 100% entry 0%,区间是负的,动画直接不生效,而且控制台不报错,非常难查。建议先用cover这种整段区间跑通,再逐步收窄。
问题二:view-timeline挂在position: fixed元素上无效。因为固定定位元素相对视口不动,"进入视口的过程"这个概念对它不成立。这个限制在规范里有说明,但文档不显眼,我调了半天才发现。
问题三:worklet 里props.get()返回的是 CSSStyleValue,不是原始数字。直接拿来做算术会得到NaN。必须用.value取数值,或者.toString()再解析。而且如果属性没被@property声明类型,.value可能不存在,得做兜底。
问题四:多个scroll-timeline同名冲突。时间轴名字是全局作用域内的标识,两个不同容器用了同一个名字,后声明的会覆盖前面的,动画会挂到错误的容器上。命名建议带模块前缀,比如--hero-scroll、--list-scroll,别图省事都叫--scroll。
问题五:滚动驱动动画和will-change一起用时的合成层爆炸。给太多元素加will-change: transform再挂滚动动画,会创建大量合成层,内存飙升、滚动反而卡。只在真正需要的那几个元素上加,且动画结束后考虑移除。
6. 一套完整的落地组合:从结构到参数的配置清单
6.1 推荐的项目结构
把不同职责拆到不同文件,避免 worklet 和主逻辑混在一起:
/styles scroll-timelines.css /* 时间轴声明与 animation-range */ indicator.css /* 指示器样式与 @property */ /scripts register-worklets.js /* addModule 注册 */ scroll-activity.js /* 活跃检测,只切类名 */ /worklets scroll-indicator.js /* paint 绘制逻辑 */这样拆的好处是:worklet 文件是独立加载的,改动它不会触发主 bundle 重建;时间轴声明集中在 CSS 里,方便全局搜索哪些元素挂了哪条轴;活跃检测的 JS 体积极小,可以内联进首屏关键路径。
6.2 参数速查表
调手感时最常改的几个值,我整理成一张表,方便直接对照调:
| 参数 | 作用 | 推荐区间 | 调过头会怎样 |
|---|---|---|---|
阻尼系数ease | 位移跟随速度 | 0.10 ~ 0.15 | 太小拖沓、太大失去阻尼感 |
| 指示器滑块最小高度 | 保证可点可看 | 24px 起 | 太小在高分屏上几乎看不见 |
| 活跃淡出延迟 | 停止滚动后多久隐藏 | 1000 ~ 1500ms | 太短闪烁、太长一直挡视线 |
animation-range起始 | 动画从元素进入多少开始 | entry 0%起 | 起始太晚动画来不及播完 |
| 轨道宽度 | 指示器视觉重量 | 3 ~ 6px | 太粗抢戏、太细看不清 |
6.3 一个容易忽略的可访问性细节
滚动驱动动画默认会跟随用户的"减少动态效果"偏好吗?答案是:部分会,部分不会,不能想当然。prefers-reduced-motion需要你显式处理。对于纯装饰性的滚动动画(比如视差、位移),在减少动态效果时应直接关掉,只保留透明度变化这种温和的过渡:
@media (prefers-reduced-motion: reduce) { .card { animation: fade-only linear both; animation-timeline: --card-reveal; } @keyframes fade-only { from { opacity: 0; } to { opacity: 1; } } }这不是可选项,是底线。滚动驱动的位移动画对前庭敏感人群不友好,关掉位移、保留淡入,是成本最低的尊重。
7. 我对这套方案边界的判断
用了几个项目之后,我对 Houdini 滚动定制的能力边界有了比较清晰的判断,这里直接说结论,省得你走弯路。
能放心交给 CSS 时间轴的:所有"进度映射"类的效果——进度条、随滚动变化的颜色/透明度、元素入场出场、视差位移。这类效果的共同特征是"位置决定状态",没有时间维度,天然适合滚动驱动,而且零 JS、性能好、可访问性容易处理。
必须用 JS 兜底的:任何需要"感知滚动意图"的逻辑——区分拖拽和惯性、做速度相关的效果、自定义阻尼。这些依赖的信息 CSS 时间轴不提供,硬做只能靠猜。JS 方案要守住一条线:只做状态判断和参数计算,把绘制和位移交给 CSS/worklet,别在主线程里每帧读写布局。
现阶段不该碰的:完全接管滚动输入、自定义滚动物理引擎。这块没有稳定的标准接口,自己用 JS 模拟的代价是可访问性、键盘导航、锚点、sticky 全面失效,除非项目明确是"视觉展示型"且能接受这些取舍,否则投入产出比很低。
最后分享一个我调这类效果时的小习惯:先把所有动画的animation-range临时设成cover 0% cover 100%,让整段过程可见,确认动画本身没问题,再逐步收窄区间。因为区间收窄后动画只在特定阶段生效,一旦不触发,你很难判断是区间写错了还是动画本身有问题。先放大范围再收,排查效率能高好几倍。这个笨办法救过我很多次,尤其是在view-timeline和固定定位元素打架的时候。