自定义滚动条别挤布局:一次 hover 抖动的处理
有些 UI 细节很小,但会把页面质感一下拉下来。比如弹窗里有一组网格按钮,默认看着居中;鼠标移进去,右侧滚动条出现,按钮突然往左挤了一点。滚动条只有几像素宽,用户未必能说出原因,但会觉得界面在抖。
这类问题的核心通常是滚动条改变了 scrollport 的宽度。只要内容排版依赖 clientWidth,原生滚动条在 hover 时出现或消失,就可能让 grid、居中按钮、两端对齐布局重新计算。
最后更稳定的做法是:原生滚动条始终隐藏,滚动能力保留;滚动条视觉交给一个 overlay 层处理。真实项目里我不再建议继续手写 thumb,而是用 v-scrollbar 指令封装 overlayscrollbars:组件只关心「谁是真正的滚动容器」,指令负责初始化、更新和销毁第三方库。
先分清滚动条的两种角色
滚动条同时承担两个角色:
- 它是交互能力:内容超过容器时,用户可以滚动。
- 它是视觉提示:告诉用户这里还有内容。
很多问题来自把这两个角色绑在一起。为了「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 的目标是预留滚动条空间,避免滚动条出现时突然挤内容。它解决的是「出现时挤一下」,但代价是滚动条区域从一开始就占着宽度。如果设计稿里的按钮是按视觉区域居中的,这个预留槽会让按钮看起来偏一边。
它适合表格、长列表、阅读区这类「宁可常驻占位,也不要内容宽度变化」的场景。对弹窗网格、浮层面板、图片选择器这种追求视觉贴合的区域,预留一条空槽经常不是想要的结果。
把视觉提示交给第三方库
更稳的写法还是把滚动能力和视觉提示拆开。滚动容器继续负责滚动,原生滚动条不占位、不显示;滚动条提示由第三方库画在 overlay 层上,不参与内容布局。
这件事当然可以自己写:隐藏原生滚动条,再用伪元素或子节点画 thumb,监听 scroll、ResizeObserver、MutationObserver,计算 scrollHeight / clientHeight / scrollTop,最后把 thumb 放回可视区域。问题是这种实现很容易继续长出边界:thumb 坐标系、内容异步撑高、触摸滚动、横向滚动、滚动容器销毁、虚拟列表频繁 mutation,都要自己补。
所以真正沉淀下来之后,我更倾向于让成熟库处理滚动条视觉和更新时机,项目里只保留一层很薄的指令封装:
<div v-scrollbar class="upload-panel__grid">
<!-- grid item -->
</div>组件仍然明确声明「这里是滚动层」;指令负责接入 OverlayScrollbars,样式负责隐藏原生滚动条和定义 thumb 视觉。
保留原滚动容器契约
这个封装的关键点落在初始化方式上,v-scrollbar 只是把这条约束固定成一个稳定入口。
很多滚动相关逻辑都默认「这个 DOM 元素就是真正的滚动根」:业务代码会在它上面监听 scroll,读写 scrollTop,无限滚动会拿它算触底,滚动恢复会拿它保存快照,虚拟列表也可能依赖它的 clientHeight。
因此接入 OverlayScrollbars 时,不要让库悄悄生成新的内部 viewport。源码包里会把 target 和 viewport 都指向当前元素:
OverlayScrollbars(
{
target: el,
elements: {
viewport: el,
padding: false,
content: false,
},
},
options,
)这样第三方库接管的是滚动条视觉,而不是滚动根语义。旧代码仍然能在原元素上读写滚动状态,迁移成本会小很多。
可复制源码
如果要让 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 都指向当前元素的实现,避免滚动监听、滚动恢复、虚拟列表和触底加载读错滚动根。内部滚动层和内容层要分清
弹窗和面板里还有一个常见坑:滚动容器选错了。
比如上传照片弹窗,顶部 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 进入可滚区域时,也应该能显示滚动提示。
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 本身不存在。
这套方案更适合这些场景:
- 弹窗里的短列表或网格。
- hover 或滚动时只需要轻提示,不希望占布局空间。
- 内容居中、按钮居中、列宽对齐对几像素变化很敏感。
- 产品视觉要求滚动条平时隐藏,但用户仍需要知道内部可滚动。
做这类 UI 时,先问一个问题:滚动条是不是参与布局的一部分。如果答案是否定的,就不要让原生滚动条承担视觉提示。让 scrollport 保持稳定,再把提示交给 overlay 层,后面会少很多奇怪的抖动问题。