自定义滚动条别挤布局:一次 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

因此更新时机至少要覆盖:

  • 用户滚动时更新。
  • 容器尺寸变化时更新。
  • 内容增删、图片加载、异步数据填充后更新。

一个轻量实现可以用 scrollResizeObserverMutationObserver 组合,再用 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 前后,滚动容器的 clientWidthoffsetWidth 不应该变化。
  • 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 保持稳定,再把提示画在上面,后面会少很多奇怪的抖动问题。