自定义滚动条别挤布局:一次 hover 抖动的处理
有些 UI 细节很小,但会把页面质感一下拉下来。比如弹窗里有一组网格按钮,默认看着居中;鼠标移进去,右侧滚动条出现,按钮突然往左挤了一点。滚动条只有几像素宽,用户未必能说出原因,但会觉得界面在抖。
这类问题的核心通常是滚动条改变了 scrollport 的宽度。只要内容排版依赖 clientWidth,原生滚动条在 hover 时出现或消失,就可能让 grid、居中按钮、两端对齐布局重新计算。
最后更稳定的做法是:原生滚动条始终隐藏,滚动能力保留;需要提示用户可以滚动时,在滚动容器上画一个绝对定位的 overlay thumb。这个 thumb 不参与布局,所以 hover 只改变视觉提示,不改变内容宽度。
先分清滚动条的两种角色
滚动条同时承担两个角色:
- 它是交互能力:内容超过容器时,用户可以滚动。
- 它是视觉提示:告诉用户这里还有内容。
很多问题来自把这两个角色绑在一起。为了「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-hover {
position: relative;
--scrollbar-hover-size: 4px;
--scrollbar-hover-track-inset: 6px;
--scrollbar-hover-thumb-top: var(--scrollbar-hover-track-inset);
--scrollbar-hover-thumb-height: 48px;
scrollbar-color: transparent transparent;
scrollbar-gutter: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hover::-webkit-scrollbar {
width: 0;
height: 0;
}
.scrollbar-hover::after {
position: absolute;
z-index: 20;
top: var(--scrollbar-hover-thumb-top);
right: 2px;
width: var(--scrollbar-hover-size);
height: var(--scrollbar-hover-thumb-height);
border-radius: 999px;
background: rgb(0 0 0 / 0.22);
content: '';
opacity: 0;
pointer-events: none;
transition: opacity 0.14s ease;
}
.scrollbar-hover[data-scrollbar-hover-scrollable='true']:hover::after,
.scrollbar-hover[data-scrollbar-hover-scrollable='true']:focus-within::after {
opacity: 1;
}这段 CSS 有几个关键点:
scrollbar-width: none和::-webkit-scrollbar { width: 0 }负责隐藏原生滚动条。scrollbar-gutter: auto避免浏览器预留一条固定槽。::after是视觉层,只盖在右侧,不参与内容布局。pointer-events: none避免这个假 thumb 抢鼠标事件。data-scrollbar-hover-scrollable='true'让没有溢出的容器不显示滚动提示。
视觉上像滚动条,但布局上它只是一个 overlay。
伪元素跟着滚动内容走的问题
伪元素放在滚动容器上时,还有一个容易漏掉的细节:它属于这个滚动容器的内容坐标系。
如果只按滚动比例算:
const thumbTop = inset + maxThumbTop * scrollRatio滚动内容往下滚时,伪元素也可能跟着内容一起移动,最后看起来不像固定在可视区域右侧的滚动条。
更稳的是把当前 scrollTop 加进去,让伪元素的位置跟上可视窗口:
// 示例:用 scrollTop 抵消滚动内容坐标系的移动
const scrollableDistance = element.scrollHeight - element.clientHeight
const trackHeight = element.clientHeight - inset * 2
const thumbHeight = Math.max(minThumbHeight, Math.round((element.clientHeight / element.scrollHeight) * trackHeight))
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const scrollRatio = element.scrollTop / scrollableDistance
const thumbTop = element.scrollTop + inset + Math.round(maxThumbTop * scrollRatio)
element.style.setProperty('--scrollbar-hover-thumb-top', `${thumbTop}px`)
element.style.setProperty('--scrollbar-hover-thumb-height', `${thumbHeight}px`)这个计算里有两个位置概念:
maxThumbTop * scrollRatio描述 thumb 在轨道上的相对位置。element.scrollTop把这个相对位置搬回当前可视区域。
如果后面把 thumb 放到滚动容器外层,或者用额外的子节点做 fixed overlay,这个公式就要跟着改。真正需要先确认的是 thumb 所在的坐标系。
什么时候更新 thumb
thumb 的位置和高度由三个东西决定:
- 容器高度:
clientHeight - 内容高度:
scrollHeight - 当前滚动位置:
scrollTop
因此更新时机至少要覆盖:
- 用户滚动时更新。
- 容器尺寸变化时更新。
- 内容增删、图片加载、异步数据填充后更新。
一个轻量实现可以用 scroll、ResizeObserver 和 MutationObserver 组合,再用 requestAnimationFrame 合并频繁更新。
// 示例:滚动容器初始化时绑定三类更新来源
const resizeObserver = new ResizeObserver(() => queueOverlayUpdate(element))
const mutationObserver = new MutationObserver(() => queueOverlayUpdate(element))
resizeObserver.observe(element)
mutationObserver.observe(element, {
childList: true,
characterData: true,
subtree: true,
})
element.addEventListener('scroll', () => queueOverlayUpdate(element), { passive: true })这里不要在每次 scroll 里同步读写一堆布局数据。滚动事件触发频率很高,直接反复读 scrollHeight / clientHeight / scrollTop 并写 CSS 变量,容易制造额外的布局压力。用 requestAnimationFrame 做一层合并,通常就够了。
内部滚动层和内容层要分清
弹窗和面板里还有一个常见坑:滚动容器选错了。
比如上传照片弹窗,顶部 header 和底部保存按钮应该固定,只有中间网格滚动。此时滚动条样式应该挂在中间网格层,而不是整个弹窗外壳上。
<section class="upload-panel">
<header class="upload-panel__header">...</header>
<div class="upload-panel__grid scrollbar-hover">
<!-- grid item -->
</div>
<footer class="upload-panel__footer">...</footer>
</section>如果把滚动放在外壳上,header 和 footer 会跟着内容滚动,overlay thumb 的坐标也会变复杂。如果把滚动放在 grid 层,外壳只负责弹窗位置和尺寸,grid 自己负责 scrollport,职责会清楚很多。
这条规则对账号弹窗、资料编辑弹窗、荣誉墙弹窗也一样:弹窗外壳负责定位和背景,header 固定,内容层滚动,底部按钮按设计固定或渐隐覆盖。滚动条提示只属于真正滚动的那一层。
验收不要只看有没有滚动条
自定义滚动条最容易漏的验收点是布局稳定性。
可以用几个很便宜的检查把问题钉住:
- hover 前后,滚动容器的
clientWidth和offsetWidth不应该变化。 - hover 前后,关键 grid item 的
DOMRect不应该变化。 - 没有溢出时,不应该显示假 thumb。
- 滚动到底时,thumb 不应该跑出可视区域。
- 键盘 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,
}))
expect(size.clientWidth).toBe(size.offsetWidth)这比截图断言更稳。截图适合最终视觉回看,但这种问题的核心是「几何有没有变」,直接量 DOMRect 更容易定位,也不容易被字体、图片加载和抗锯齿影响。
什么时候不用这套方案
overlay thumb 不是所有地方的默认答案。
长表格、代码编辑器、富文本编辑器、虚拟列表、无障碍要求很高的后台系统,可能更适合保留原生滚动条。原生滚动条有系统一致性,也有更好的用户预期。移动端也经常不需要额外画 hover scrollbar,因为 hover 本身不存在。
这套方案更适合这些场景:
- 弹窗里的短列表或网格。
- hover 时只需要轻提示,不希望占布局空间。
- 内容居中、按钮居中、列宽对齐对几像素变化很敏感。
- 产品视觉要求滚动条平时隐藏,但用户仍需要知道内部可滚动。
做这类 UI 时,先问一个问题:滚动条是不是参与布局的一部分。如果答案是否定的,就不要让原生滚动条承担视觉提示。让 scrollport 保持稳定,再把提示画在上面,后面会少很多奇怪的抖动问题。