CSS 滚动条淡入淡出效果实现技术方案
要解决什么问题
我们想做的效果很朴素:滚动容器平时不显示滚动条,鼠标移进去或者正在滚动时滚动条淡入,离开后淡出。macOS 的系统滚动条就是这个行为,看着干净。
但真动手做会发现,浏览器给的滚动条样式控制能力非常有限,而且滚动条本身不参与 CSS 过渡。这篇记录一下踩过的几条路和最后的方案。
为什么不能直接给滚动条加 transition
第一反应是这样写:
.scroll-box::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.3s;
}
.scroll-box:hover::-webkit-scrollbar-thumb {
opacity: 1;
}
这段代码不生效。原因是 ::-webkit-scrollbar 系列伪元素不是普通的渲染盒,它由浏览器的滚动条绘制逻辑处理,opacity 和 transition 在上面都不起作用。属性值会切换(比如 background 换颜色是立即生效的),但过渡帧不会有。
所以 ::-webkit-scrollbar-* 能做的只是「静态换样式」,做不了动画。
方案一:用 background 的 rgba 做「假淡入」
既然 opacity 不行,退一步用透明色。滑块的颜色改成完全透明,hover 时改成有色:
.scroll-box {
overflow-y: auto;
}
.scroll-box::-webkit-scrollbar {
width: 8px;
}
.scroll-box::-webkit-scrollbar-track {
background: transparent;
}
.scroll-box::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 4px;
}
.scroll-box:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.28);
}
这个方案能用,但是硬切换,没有渐变过程。而且它有两个更实际的问题:
一是滚动条槽位始终占着 8px 宽度,内容宽度不会变,这点其实是好事(避免布局跳动),但如果希望「不显示时完全不占位」就做不到。
二是 ::-webkit-scrollbar 是非标准前缀伪元素,Firefox 完全不认。Firefox 走的是标准属性 scrollbar-width 和 scrollbar-color,而 scrollbar-color 只能给 thumb 和 track 各设一个颜色,粒度更粗:
.scroll-box {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.scroll-box:hover {
scrollbar-color: rgba(0, 0, 0, 0.28) transparent;
}
有意思的是 scrollbar-color 是可动画属性,Firefox 里给它加 transition: scrollbar-color 0.3s 确实能过渡。但这只解决了 Firefox,Chrome 系还是硬切。
方案二:自绘滚动条(覆盖层)
要完全掌控动画就得放弃原生滚动条,自己画一个。思路是隐藏原生滚动条,用一个绝对定位的 div 当滑块,用 JS 同步位置。
隐藏原生滚动条同时保留滚动能力:
.scroll-box {
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* 旧 Edge/IE */
}
.scroll-box::-webkit-scrollbar {
display: none; /* Chrome / Safari */
}
然后覆盖层:
<div class="wrap">
<div class="scroll-box" ref="boxRef">
<!-- 内容 -->
</div>
<div class="fake-bar" :style="barStyle"></div>
</div>
.wrap {
position: relative;
}
.fake-bar {
position: absolute;
right: 2px;
width: 6px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.28);
opacity: 0;
transition: opacity 0.28s ease;
pointer-events: none; /* 不挡内容点击,除非要实现拖拽 */
}
.wrap:hover .fake-bar,
.wrap.is-scrolling .fake-bar {
opacity: 1;
}
滑块的高度和位置需要按滚动比例算:
function syncBar(el, bar) {
const { scrollTop, scrollHeight, clientHeight } = el;
if (scrollHeight <= clientHeight) {
bar.style.display = 'none';
return;
}
bar.style.display = '';
const ratio = clientHeight / scrollHeight;
const barH = Math.max(clientHeight * ratio, 24); // 给个最小高度
const maxTop = clientHeight - barH;
const top = (scrollTop / (scrollHeight - clientHeight)) * maxTop;
bar.style.height = `${barH}px`;
bar.style.transform = `translateY(${top}px)`;
}
用 transform 而不是 top 是为了走合成层,避免每帧触发布局。
滚动时的淡入靠一个防抖的 class:
let timer = null;
el.addEventListener(
'scroll',
() => {
syncBar(el, bar);
wrap.classList.add('is-scrolling');
clearTimeout(timer);
timer = setTimeout(() => wrap.classList.remove('is-scrolling'), 900);
},
{ passive: true },
);
{ passive: true } 这里必须加。scroll 监听器如果不声明 passive,浏览器不能确定你会不会调 preventDefault,滚动响应会有延迟。
自绘方案的代价
看起来完美,但引入的问题不少,这也是最后为什么在很多场景下我们还是退回方案一的原因:
容器尺寸变化要重算。 内容动态增删、窗口 resize、容器折叠展开都会让 scrollHeight 变,必须用 ResizeObserver 监听容器和内容:
const ro = new ResizeObserver(() => syncBar(el, bar));
ro.observe(el);
ro.observe(el.firstElementChild);
忘了这一步,滑块高度就会跟内容不匹配,看起来像 bug。
移动端触摸滚动不触发 hover。 触屏没有 hover 状态,只能靠 is-scrolling 那条路径。而且移动端原生滚动条本来就是自动淡入淡出的,自绘反而是负优化,实际做法是用媒体查询把自绘逻辑限制在指针设备上:
@media (hover: none) {
.fake-bar {
display: none;
}
}
丢失原生能力。 拖拽滑块、点击槽位翻页、键盘 PageUp/PageDown 的视觉反馈都得自己实现。要支持拖拽就得去掉 pointer-events: none,再处理 pointerdown/move/up 和边界钳制,代码量翻倍。
无障碍。 隐藏原生滚动条不影响键盘滚动和屏幕阅读器(滚动容器本身还是可聚焦可滚的),但如果给自绘滑块加了交互,需要保证键盘也能操作,别做成只能鼠标用。
最后的取舍
我们的判断标准是这样的:
页面级、内容量不确定的长列表用方案一。::-webkit-scrollbar 加透明色,配合 Firefox 的 scrollbar-color 过渡。没有淡入动画但零 JS、零维护成本、行为完全原生,滚动性能也没有额外开销。
只有对视觉一致性要求高的独立组件(比如自定义下拉面板、代码预览框)才上方案二,且必须配 ResizeObserver,并在 (hover: none) 下关掉。
另外补一个细节:给滚动容器加 overflow-y: auto 时,Chrome 下滚动条出现/消失会导致内容宽度跳动。用 scrollbar-gutter: stable 可以预留槽位避免跳动,这是标准属性,比过去用 overflow-y: scroll 强制常显要好:
.scroll-box {
overflow-y: auto;
scrollbar-gutter: stable;
}
不过它在自绘方案里没意义——原生滚动条已经被隐藏了,本来就不占位。

还没有留言,来发表第一条吧~