自定义滚动条别挤布局:一次 hover 抖动的处理
弹窗里有一组网格按钮,默认看着居中;鼠标移进去,右侧滚动条出现,按钮突然往左挤了一点。滚动条只有几像素宽,用户未必能说出原因,但会觉得界面在抖。
这类问题的核心通常是滚动条改变了 scrollport 的宽度。只要内容排版依赖 clientWidth,原生滚动条在 hover 时出现或消失,就可能让 grid、居中按钮、两端对齐布局重新计算。
最后更稳定的做法是:原生滚动条始终隐藏,滚动能力保留;滚动条视觉交给一个 overlay 层处理。如果是在自己项目里,我仍然会用 v-scrollbar 指令封装 overlayscrollbars,让组件只关心「谁是真正的滚动容器」。但这次换成了浏览器插件视角:知乎页面不是我们的组件树,不能给每个滚动容器补指令,只能在 content script 里选择目标、注入样式、初始化第三方库,并准备好取消和回退。
浏览器滚动先有容器,再有滚动条
一个元素能不能滚动,核心不取决于滚动条长什么样,而取决于它有没有形成可滚动溢出区域。内容尺寸超过可视区域,且 overflow 允许滚动时,浏览器会给这个元素建立 scroll container。用户真正看到的可视窗口通常叫 scrollport;内容总尺寸和 scrollport 的差值,决定了 scrollTop / scrollLeft 能移动的范围。
页面根滚动又是一个特殊版本。普通内部容器的滚动状态在元素自己身上,业务代码会读写 element.scrollTop;页面根滚动常常通过 window.scrollY、window.scrollTo()、浏览器刷新后的滚动恢复、锚点跳转和页面内查找一起参与。也正因为它牵涉浏览器级能力,插件直接接管 body 要比接管某个评论弹窗或图片列表危险得多。
滚动条本身只是这个滚动状态的视觉和交互外壳。浏览器先知道 scrollport 有多大、内容有多长、当前位置在哪里,再把这些比例映射成 thumb 的长度和位置。内容越长,thumb 越短;越往下滚,thumb 越靠下。这个长度不是 scrollbar-width 里的 width,也不是 CSS 里随手写一个 height: 10px 就能决定的。
真正容易导致布局抖动的是滚动条占不占布局空间。Chrome 的滚动条样式文档把滚动条分成 classic scrollbar 和 overlay scrollbar:classic 会放进 gutter,占用布局空间;overlay 浮在内容上,不占布局空间。Windows 上常见的是 classic,macOS 会受系统设置、输入设备和浏览器实现影响,鼠标场景里也可能看到更接近 classic 的表现。
所以 hover 时出现滚动条导致内容横向挤压,根因通常是 scrollport 的可用宽度变化,滚动能力本身没有变化。CSS 或 JS 要解决这类问题,目标是让滚动条视觉不要参与内容布局。
滚动能力和视觉提示要拆开
滚动条同时承担两个角色:
- 它是交互能力:内容超过容器时,用户可以滚动。
- 它是视觉提示:告诉用户这里还有内容。
很多问题来自把这两个角色绑在一起。为了「hover 时才看到滚动条」,直接让原生滚动条在 hover 时显示;结果视觉提示出现了,scrollport 也跟着变窄了。
在 macOS 默认 overlay scrollbar 下,这个问题可能不明显。到了 Windows、某些浏览器设置、嵌入 WebView,或者项目自己设置了 scrollbar-gutter 后,滚动条是否占空间就会变得很关键。一个面板如果要保证网格列宽、居中按钮和右侧浮层不抖,就不能让滚动条是否可见决定布局宽度。

两种没有满足这次布局要求的方案
第一种是 hover 时切换原生滚动条样式。
.panel {
overflow-y: auto;
scrollbar-width: none;
}
.panel:hover {
scrollbar-width: thin;
}这段代码在某些环境里会直接改变滚动条占位。只要滚动条从 none 变成 thin 后参与布局,clientWidth 就会变,网格列宽也会变。对图片网格、卡片网格、居中按钮来说,这种变化就是肉眼可见的抖动。
第二种是直接上 scrollbar-gutter: stable。
.panel {
overflow-y: auto;
scrollbar-gutter: stable;
}scrollbar-gutter: stable 的目标是预留滚动条空间,避免滚动条出现时突然挤内容。它解决的是「出现时挤一下」,但代价是滚动条区域从一开始就占着宽度。如果设计稿里的按钮是按视觉区域居中的,这个预留槽会让按钮看起来偏一边。
它适合表格、长列表、阅读区这类「宁可常驻占位,也不要内容宽度变化」的场景。对弹窗网格、浮层面板、图片选择器这种追求视觉贴合的区域,预留一条空槽经常不是想要的结果。
标准属性只给厚度提示
scrollbar-width 这个名字容易误导。它在 CSS Scrollbars Level 1 里的语义是滚动条厚度,取值也只有 auto、thin 和 none;MDN 也把它解释成给浏览器一个「普通、较细或隐藏」的提示。它不负责精确指定 10px,也不负责控制 thumb 沿滚动方向的长度。
Chromium 当年的 scrollbar-width 初始实现把这个属性放进 style,scrollbar theme 在计算滚动条 thickness 和 margin 时读取它:none 对应 0 厚度,thin 走平台主题里的较细尺寸。它控制的是浏览器原生滚动条主题怎么取「厚度」,不是给页面作者一个可精确布局的滚动条盒模型。
所以这段代码的意思不是「纵向时调宽度,横向时调长度」:
.panel {
scrollbar-width: thin;
}纵向滚动条变细,是因为它的厚度变小;横向滚动条变细,也是因为它的厚度变小,只是横向滚动条的厚度表现在视觉上的高度。thumb 的长度仍然由可视区域、内容总长度和滚动比例决定。
真正会看到 width / height 对调的是非标准的 WebKit 滚动条伪元素。WebKit 的介绍里写过,::-webkit-scrollbar 上的 width 对应纵向滚动条宽度,height 对应横向滚动条高度:
::-webkit-scrollbar {
width: 10px;
height: 10px;
}这套伪元素还提供 :horizontal、:vertical 这样的方向伪类,可以分别覆盖横向和纵向。::-webkit-scrollbar 属于 WebKit 私有扩展;CSS Scrollbars Level 1 只标准化颜色和厚度。滚动条内部结构、布局和精确样式在不同操作系统上差异很大,规范没有把这些部分开放成统一的选择器。
Chrome 现在还会处理标准属性和私有伪元素的优先级:标准 scrollbar-color / scrollbar-width 生效时,旧的 ::-webkit-scrollbar-* 样式不一定还能按原来方式覆盖。它们适合做局部兼容兜底,但不适合作为稳定的跨浏览器组件 API。
CSS 逻辑属性里确实有 inline-size、block-size、min-inline-size、min-block-size,它们是普通盒模型里 width、height、min-width、min-height 的逻辑写法,会跟着 writing mode 变化。但这不是滚动条方向 API。它解决的是文本流和书写方向,不会自动表达「横向滚动条的厚度」或「纵向 thumb 的长度」。
OverlayScrollbars 的主题变量好用,就在于它自己生成了横向和纵向滚动条节点,然后在库 CSS 里把物理属性差异藏起来。它不是运行时临时判断「这条滚动条该把变量写到 width 还是 height」,而是分别渲染 .os-scrollbar-horizontal 和 .os-scrollbar-vertical,再用方向 class 把同一组 CSS 变量映射到不同物理属性。主题侧只需要写语义变量:
.os-scrollbar.os-theme-app {
--os-size: 10px;
--os-handle-min-size: 42px;
--os-handle-perpendicular-size: 100%;
}其中 --os-size 是滚动条整体厚度;--os-handle-min-size 是 thumb 沿滚动方向的最小长度;--os-handle-perpendicular-size 是 thumb 垂直于滚动方向的尺寸。OverlayScrollbars 官方文档也按这个语义解释:横向滚动条里它对应高度,纵向滚动条里它对应宽度。
库内部等价于按方向写了两套映射:
.os-scrollbar-horizontal {
height: var(--os-size);
}
.os-scrollbar-vertical {
width: var(--os-size);
}
.os-scrollbar-horizontal .os-scrollbar-handle {
min-width: var(--os-handle-min-size);
height: var(--os-handle-perpendicular-size);
}
.os-scrollbar-vertical .os-scrollbar-handle {
min-height: var(--os-handle-min-size);
width: var(--os-handle-perpendicular-size);
}后来把插件里的滚动条宽度调回 10px 时,我更愿意改 --os-size,而不是直接写 .os-scrollbar-handle { min-width: 42px; min-height: 42px; }。后者会把横向长度和纵向厚度混在一起,竖向滚动条很容易被 min-width 撑得过粗;前者把「整体厚度」「沿轴最小长度」「垂轴尺寸」分开,后面要微调横向或纵向也更清楚。
让 overlay 层接管视觉提示
更稳的写法还是让滚动能力和视觉提示分开。滚动容器继续负责滚动,原生滚动条不占位、不显示;滚动条提示由第三方库画在 overlay 层上,不参与内容布局。
这件事当然可以自己写:隐藏原生滚动条,再用伪元素或子节点画 thumb,监听 scroll、ResizeObserver、MutationObserver,计算 scrollHeight / clientHeight / scrollTop,最后把 thumb 放回可视区域。问题是这种实现很容易继续长出边界:thumb 坐标系、内容异步撑高、触摸滚动、横向滚动、滚动容器销毁、虚拟列表频繁 mutation,都要自己补。
后来我又回头看了 OverlayScrollbars 的官方文档。这类库真正有用的地方不只在主题样式,而是它把初始化对象、生成元素、viewport、scrollbar slot、取消初始化这些边界都暴露了出来。项目可以明确决定:滚动语义留在哪个元素上,视觉滚动条挂在哪一层,什么时候因为浏览器原生能力更合适而放弃初始化。
这次之后,我更倾向于让成熟库处理滚动条视觉和更新时机。如果是在自己项目里,可以只保留一层很薄的指令封装:
<div v-scrollbar class="upload-panel__grid">
<!-- grid item -->
</div>组件仍然明确声明「这里是滚动层」;指令负责接入 OverlayScrollbars,样式负责隐藏原生滚动条和定义 thumb 视觉。
换到浏览器插件里,代码形态会变。知乎 DOM 已经由站点生成,.css-* hash 选择器也可能随着版本变化。滚动条能力更适合做成一个独立 feature:运行时找到目标元素,注入 feature 自己的 Stylus 产物,调用 OverlayScrollbars 初始化;feature 因为动态 DOM 重跑时只更新实例。提前隐藏原生滚动条的容器还要单独考虑失败回退:保留滚动能力不等于原生滚动条的视觉也能自动恢复。
这层封装还有一个关键点:保留原滚动容器契约。v-scrollbar 只是把这条约束固定成一个稳定入口。
很多滚动相关逻辑都默认「这个 DOM 元素就是真正的滚动根」:业务代码会在它上面监听 scroll,读写 scrollTop,无限滚动会拿它算触底,滚动恢复会拿它保存快照,虚拟列表也可能依赖它的 clientHeight。
因此接入 OverlayScrollbars 时,不要让库悄悄生成新的内部 viewport。官方文档里也专门提到过,可以在对象初始化时指定已有元素作为 viewport,这对「DOM 结构固定、还要和其他库协作」的场景很有用。源码包里会把 target 和 viewport 都指向当前元素:
OverlayScrollbars(
{
target: el,
elements: {
viewport: el,
padding: false,
content: false,
},
},
options,
)这样第三方库接管的是滚动条视觉,而不是滚动根语义。旧代码仍然能在原元素上读写滚动状态,迁移成本会小很多。
插件里 body 是另一条风险边界
局部容器和页面根滚动不是一回事。局部容器选错,通常是某个弹窗或列表表现异常;body 选错,影响的是整个页面的滚动语义。
这次排知乎页面滚动条时就遇到了这个问题:全屏看图、页面从不超长变成超长、浏览器刷新后的滚动恢复,都可能因为系统滚动条出现 / 消失导致页面横向抖一下。直觉上可以把 body 也交给 OverlayScrollbars,让滚动条像 overlay 一样浮在内容上。但浏览器扩展是在第三方站点上做增强,页面里已经有无限流、弹窗、锚点、滚动恢复和站点自己的监听逻辑,直接强制接管 body 风险很高。
OverlayScrollbars 官方也把 body 当成特殊场景处理:如果应用在 body 上,滚动位置仍然可以用 window.scrollX、window.scrollY、window.scrollTo 这类原生 API;同时可以把 cancel.body 设为 null,让库在判断会影响 window.scrollTo 等原生能力时主动取消初始化。
OverlayScrollbars(
{
target: document.body,
cancel: {
nativeScrollbarsOverlaid: true,
body: null,
},
},
options,
)这是库提供的保守配置:能安全初始化就用自绘滚动条;库判断不适合时取消初始化。知乎增强当前为了接管页面滚动,实际把 cancel.body 和 cancel.nativeScrollbarsOverlaid 都设成了 false,与上面的保守示例不同。强制接管能保持页面滚动条外观一致,但窗口滚动 API、浏览器滚动恢复、锚点跳转、查找定位、弹窗锁滚动和无限流触底都必须单独回归,不能把库的取消机制当作现成兜底。
还有一个细节容易忘:如果初始化的不是 body,后续读写滚动位置应该先从实例里拿 viewport。官方 FAQ 也是这样分的:body 用 window 滚动 API,其他元素用 instance.elements().viewport。
初始化前的原生滚动条
即使自绘滚动条不会挤布局,初始化之前仍有一小段空窗。OverlayScrollbars 官方文档说明,库需要时间创建并插入节点;如果浏览器已先绘制原生滚动条,用户就会看到它闪一下,然后才切换为自绘条。把原生条的隐藏样式放在「实例初始化成功」的标记下,只能处理初始化之后,管不到这段空窗。
能控制页面源码时,官方给出的桥接方式是在目标元素首次渲染时就写 data-overlayscrollbars-initialize,并让库的 CSS 在首帧前加载。接管 body 时,html 和 body 都需要这个属性;局部滚动容器只需在该容器上写。属性不能等到 mounted、useEffect 或初始化回调里才补,否则原生条可能已经绘制。这里隐藏的是原生滚动条视觉,不是把滚动容器设成 display: none。
<div v-scrollbar data-overlayscrollbars-initialize class="upload-panel__grid">
<!-- grid item -->
</div>这对本文源码包里的自定义 v-scrollbar 只是一个可选的首帧补充,不是指令自动提供的行为:要由模板或 SSR 输出属性,并确认指令确实会初始化这个元素。若指令可通过 v-scrollbar="false" 禁用,桥接属性也要随启用状态一起撤掉;否则实例没装,原生条却被隐藏。使用官方 Vue OverlayScrollbarsComponent 时情况不同,它的模板已经在根元素写了这个属性,不必再手动重复加;但若用 useOverlayScrollbars composable 或自己写组件,仍需检查初始 DOM。官方组件支持 defer 延后初始化,桥接属性在此时尤其有用。SSR 场景还要确认 CSS 已在首屏加载;如果组件只在客户端异步创建,也要检查创建后到初始化之间是否跨过一次绘制。
扩展没有目标网站的组件模板可改,但不等于必然错过所有首帧。若 content CSS 在 document_start 注入,就可以对已确认的内部滚动容器选择器提前隐藏原生条,覆盖它们稍后动态插入、而 JS 尚未初始化的时段。知乎增强目前就是对评论、成员等已知内部容器用 scrollbar-width: none 和 ::-webkit-scrollbar { display: none; } 预先处理;页面根滚动则保留原生条作为视觉兜底。选择器不能无差别扩到所有滚动容器:只要扩展脚本失败、功能关闭或站点 class 改了,预先隐藏的容器仍可滚动,却可能没有可拖动的滚动条。这个代价要随选择器维护和失败场景一起验收。
官方还提醒,普通桥接 CSS 在 JavaScript 完全不执行时也会隐藏原生条;支持该边界的站点可评估 overlayscrollbars.scriptingenabled.css 及 scripting 媒体特性的浏览器兼容范围。不过它解决的是禁用脚本,不会自动处理脚本运行后初始化失败的情形。
插件不是项目源码补丁
自己项目里接入滚动条,思路是「我定义一个滚动层,然后把能力挂上去」。浏览器插件里正好反过来:页面已经存在,DOM、事件、滚动容器、CSS 变量和异步渲染都归站点所有,插件只能叠一层运行时增强。
这会改变工程判断。普通深色样式、链接颜色、评论边框这类改动,本质是 CSS 覆盖,放在样式 feature 里让 Vite 统一打包就够了。图片最大高度、站外直链、复制清理依赖 DOM 和事件,需要做成幂等 feature,跟着知乎动态插入内容重复运行。整页 overlay scrollbar 又高一层:它会隐藏原生滚动条,还会让第三方库插入自己的滚动条节点,属于更高风险的运行时接管。
所以插件里的这类 feature 至少要回答四个问题:
- 为什么一定要接管,而不是只靠 CSS 变量或局部样式。
- 接管的是哪个元素,是内部滚动容器,还是
body。 - 第三方库取消初始化、浏览器原生能力已经足够、或者目标元素不存在时,滚动能力和滚动条视觉分别怎样回退。
- feature 重复运行、站点局部刷新、弹窗动态插入时,是否会重复初始化或留下旧节点。
这也是为什么这次知乎增强里没有把 OverlayScrollbars 当成一个全局基础设施,而是先放进单独 feature。它可以被关闭,可以单独写 README,可以用单测固定 cancel.body: null 这类初始化边界;后续如果抽成通用插件,也能整块迁走。
项目源码包只固定局部契约
配套源码包仍然保留,它适合自己项目里的局部滚动容器。它只做一件事:把「原元素仍然是滚动根,overlay 只负责视觉」这条约束落成可迁移代码。如果要让 Agent 直接接入项目源码,可以把下面这段 prompt 贴给它:
请接入 v-scrollbar overlay scrollbar 方案。
源码包根路径:https://shengsheng.fun/files/custom-scrollbar-hover-layout-shift/kits/overlay-scrollbar-directive/
先读取 README.md、MANIFEST.json、FILES.json 和 CHANGELOG.md,再按 FILES.json 读取 copy/ 与 examples/。
迁移指令、Nuxt 插件、全局样式和单测;只在真正滚动的元素上使用 v-scrollbar。
保留 target 和 viewport 都指向当前元素的实现,避免滚动监听、滚动恢复、虚拟列表和触底加载读错滚动根。
不要默认接管 body;如果确实要处理页面根滚动,使用 OverlayScrollbars 官方的 cancel.body: null,让库在会影响 window.scrollTo 等原生能力时取消初始化。能控制源码时,滚动层不要选错
弹窗和面板里还有一个常见坑:滚动容器选错了。
比如上传照片弹窗,顶部 header 和底部保存按钮应该固定,只有中间网格滚动。此时滚动条样式应该挂在中间网格层,而不是整个弹窗外壳上。
<section class="upload-panel">
<header class="upload-panel__header">...</header>
<div v-scrollbar class="upload-panel__grid">
<!-- grid item -->
</div>
<footer class="upload-panel__footer">...</footer>
</section>如果把滚动放在外壳上,header 和 footer 会跟着内容滚动,OverlayScrollbars 也会接管错误的滚动根。如果把滚动放在 grid 层,外壳只负责弹窗位置和尺寸,grid 自己负责 scrollport,职责会清楚很多。
这条规则对账号弹窗、资料编辑弹窗、荣誉墙弹窗也一样:弹窗外壳负责定位和背景,header 固定,内容层滚动,底部按钮按设计固定或渐隐覆盖。滚动条提示只属于真正滚动的那一层。
验收不要只看有没有滚动条
自定义滚动条最容易漏的验收点是布局稳定性。
验收时同时检查尺寸、初始化状态和回退行为:
- hover 前后,滚动容器的
clientWidth和offsetWidth不应该变化。 - hover 前后,关键 grid item 的
DOMRect不应该变化。 - 初始化成功后,滚动容器应该带有
data-scrollbar-engine="overlay"。 - 滚动到底时,OverlayScrollbars 的 handle 不应该跑出可视区域。
- 键盘 focus 进入可滚区域时,也应该能显示滚动提示。
- 插件 feature 重复运行后,不应该重复初始化同一个目标,也不应该留下多组自绘滚动条节点。
- 首帧检查不能只看初始化后的截图;打开动态弹窗时要观察自绘条挂载前是否闪现原生条,以及初始化失败时是否还能看到可操作的滚动提示。
- 如果实验性接管
body,要额外验证window.scrollTo(0, 0)、浏览器刷新滚动恢复、锚点跳转、页面内查找定位、弹窗锁滚动和无限流触底;库取消初始化时,页面应该回到原生滚动条,不应该留下半套隐藏样式。
Playwright 里可以写成这种白盒断言:
const panel = page.locator('.upload-panel__grid')
const item = panel.locator('.upload-panel__slot').first()
const before = await item.boundingBox()
await panel.hover()
const after = await item.boundingBox()
expect(after?.x).toBe(before?.x)
expect(after?.width).toBe(before?.width)
const size = await panel.evaluate((element) => ({
clientWidth: element.clientWidth,
offsetWidth: element.offsetWidth,
engine: element.getAttribute('data-scrollbar-engine'),
}))
expect(size.clientWidth).toBe(size.offsetWidth)
expect(size.engine).toBe('overlay')这比截图断言更稳。截图适合最终视觉回看,但这种问题的核心是「几何有没有变」,直接量 DOMRect 更容易定位,也不容易被字体、图片加载和抗锯齿影响。
什么时候保留原生滚动条
overlay scrollbar 不是所有地方的默认答案。
长表格、代码编辑器、富文本编辑器、无障碍要求很高的后台系统,可能更适合保留原生滚动条。原生滚动条有系统一致性,也有更好的用户预期。移动端也经常不需要额外处理 hover scrollbar,因为 hover 本身不存在。
页面根滚动也应该更保守。尤其是浏览器扩展、用户脚本、第三方站点增强这类场景,代码并不拥有整页布局和所有滚动逻辑。能只处理内部弹窗和列表,就不要急着改 body;真要改,也要把它当成机会性增强,而不是硬依赖。
这套方案更适合这些场景:
- 弹窗里的短列表或网格。
- hover 或滚动时只需要轻提示,不希望占布局空间。
- 内容居中、按钮居中、列宽对齐对几像素变化很敏感。
- 产品视觉要求滚动条平时隐藏,但用户仍需要知道内部可滚动。
做这类 UI 时,关键判断很简单:滚动条到底是不是布局的一部分。如果答案是否定的,就不要让原生滚动条承担视觉提示。先让 scrollport 保持稳定,再把提示交给 overlay 层,后面会少很多奇怪的抖动问题。