Article cover

一根上流滚动条的诞生

花了两天,做了一个有点复杂的小玩意:本站右侧的那根滚动条,觉得这是一个很上流的玩意,于是把它生出这玩意的四十八小时脑子里面都在想什么全都呕吐出来 c⌒っ.ω.)っ。

世人苦滚动条久矣,有一个看似统一的标准,但是 Blink 先森和它的前妻又有一套爆炸复杂的 CSS 规则,它复杂到我几乎没有动自己手写规则的念头,清一色都在用在线生成器做一个凑合。

你想要让所有平台都有稳定的用户体验,这事情几乎是不可能的,移动端有移动端的脾气,各个浏览器引擎有自己的审美。而且也有一些很现实的事情,滚动条作为一个 Native 组件几乎不会允许你过分自由地修改它的样式。煮个栗子,直到 2025 年 5 月,你没什么原生跨平台的方法让一个半透明的拖拽条覆盖到网页内容上。

The Mistake

有的时候你真的很希望提供一个沉浸式的体验,但是那根又长又粗的玩意支楞在窗口右面会让人觉得很不爽。所以人们会千方百计的把它变细一点,让人们没办法发觉它。颇有「发臭的东西就要用盖子盖起来」之哲学。但这种设计实践显然伤害了可访问性,滚动条沦落为纯粹的阅读进度指示器,几乎没有人会主动把鼠标移动到那根孱弱的棒子上做任何搓擦。

这件事情并没有停留在浏览器内部,在移动端系统,主流的设计系统同样做了这个「设计退化」,上次你在手机上搓滚动条是什么时候?事实上,很多滚动条已经没有交互功能了。看来设计师们在让你得腱鞘炎疯狂滚动和一个精致干净的界面之间选择了后者。

此外,无限滚动、懒加载这些新兴技术让滚动条的交互变得很尴尬,你把它拖拽到边界不松手之后就会触发高频翻页操作。还有热区冲突的问题,内容要避让滑轨的热区,对于触屏来讲,这个区域要比滑轨区域宽一些,因为你得考虑误触。另外,各种稀奇古怪的系统手势浏览器手势也会导致误触。你看,这些林林总总的的麻烦事的确构成了 UX 设计师集体翻桌大喊「林北不玩了」的动机(但我们很难说这种翻桌是正确的 ¯\_(ツ)_/¯)。

不过的确是有产品在逆潮流做一些很酷的事情,比如 VS Code,他们会在滚动条上显示 Git 的增删改 Diff 信息,会显示 Linter 报错,也会显示你的光标在哪里。而且整个滚动条被画得巨宽(相对而言),一看就是对开发者的爱抚满怀期待。

把视角转回本站。我之前一直对滚动条过于扎眼不满,而且它会让题头图右侧有一个恼人的空白。基于对视觉的偏执,我也加入了糟糕设计师的行列,把那根棒子磨细,并且把颜色调得和背景色一样,试图牺牲可访问性,让我自己的审美感到愉悦。

这很明显是错误的行为。

此外,如你所知,敝博客有换页过场动画 [1],但你可能不知的是,在动画播放的时候,右侧进度条会很猖狂地跳一下。因为旧内容消失之后,页面会被暂时清空,让滚动条突然变长,等新内容上来之后它又会突然变短。尽管我已经尽力让滚动条不那么显眼了,但还是有好几次,换页的时候我的眼睛被右上角的闪烁给抓住。这是一个很糟糕的注意力引导,你希望用户专注在动画之上,以此掩蔽数据加载造成的迟滞。结果半路杀出来了个一会长一会短的棒子,破坏了精心设计的整体流畅体验,这不是我希望看到的。

正巧裙友的博客在一轮大范围性能调优的时候碰到了自定义滚动条相关的破事,想着只要有两个人能用得上,那我就有理由抽象出来一个玩意把事儿办了。

The Fix

因为整个组件都是 LLM 搓的,我只负责提需求、做致死量的 QA、以及盯着它有没有搞出什么严重的架构问题,所以下面我主要讲设计思考和 QA 过程,并尽量避免把不属于我自己的工作量说成我自己的。

你可能会觉得一个进度条,跟页面绑在一起不就完事了,屁大点事哪值得单独水一篇文章昭告天下,跟个老母鸡[2]似的看着真无聊。但当我把所有功能和 Bug 调完之后,发现整个组件有一千零四十行代码。

嚯,就一个滚动条,这码量,那叫一个气派。

这里面有一些比较务实的考虑,比如出于性能考虑,我没有想跟 CSS / HTML 打交道,因为那一堆 DOM 管理起来很麻烦,CSS 叠来叠去也很难管理,座标也很难算。最重要的是,在读屏器可访问性上这不是一个什么需要考虑的事情,所以用 Canvas 直接唰唰往上画是性能最好,且开发者友好的。这纯粹属于视觉系前端的过来人经验。

为了处理之前讲到的那个滚动条跳变的问题,目前我的实践是给滚动条长度上加一个阻尼适中的 LERP 动画[3]函数,这样跳变就会被冲淡成一系列柔和的伸缩动效,不会 duang 的一下很油很亮把你的目光吸走。这里有一个很值得注意的点是,不要用 Mutation Observer 监听整个页面的 DOM 变化,用 Resize Observer 监听最外面那层元素。因为你收到 DOM 变化之后有可能会做一些操作触发 DOM 更新,这样就无限回圈了。

另外一个小 Tip 是,尽量全都用观察者模式不要用指令模式。换言之,事件发生变化的时候触发动效 RAF 的 Loop,然后让 Loop 里面的函数自己去抓变量决定每帧渲染什么。不要把 DOM 那边发来的事件跟动画逻辑混在一起,不然会带来时序管理灾难,会带来不幸.svg

剩下的就是一些小交互细节,比如滚动条的位置也加上 Lerp 让整个体验更加流畅,加一个定时自动隐藏,鼠标悬浮到热区内自动滑出的动效,确保它不会一直在那边扎用户的眼睛。此外,我这边做了一个比较细的调整,我的滚动调是向右滑动并且淡出消失的,移动轨迹的阻尼和透明度的阻尼不一样,组合出来一种「沉入深部」的视觉体验,属于那种你不盯着看大概率看不出来,但是我做了我就很爽的东西。

再比如,你点击整个滑轨的时候,页面要跳到那个位置。当用户拖整个滚动条的时候,页面要跟着实时同步。在你拖拽滚动条的时候我就没有加什么阻尼了,页面滚动会完全和你的操作同步。我试了加微量阻尼,但总感觉手感黏糊糊的。实际上这是一个个人品味的问题,很多设计风味浓重的网站都会给滚动配「平滑曲线」,搞得滚动很不跟手,或者做一些很生硬的 Snap。设计师对体验有思考我都尊重,作为用户我用起来是不大爽,所以这里还是以我爽为主。

一点你或许希望关注的小细节是,让热区响应区域比进度条宽一点,这样哪怕老罗的胡萝卜手指也能自如操作(噗)。此外,因为你的滚动条热区已经直接盖在内容上了,所以模板层面注意做交互元素避让。特别是移动端,所有元素都紧贴屏幕边缘,特别容易犯错。我这边就犯了个热区盖住右上角头像的错误。

浏览器和系统有属于它们自己的手势,你必须得正确处理这些手势之间的冲突,专门写一小段逻辑对用户意图做解读[4]。还有用户的手指原本在热区外,后面意外进入热区导致的奇妙冲突。总之触屏一直以来都是一个很麻烦的东西,多测测不是什么坏事。

记得处理 No JS 的情况,如果用户没有开 JS 那么浏览器应该 Fallback 回传统滚动套,否则页面就没滚动条可用了。这块不难写也就一两行 JS 把 JS 运行情况标注清楚,然后在没有 JS 的情况下启用另外一组 CSS 即可。

如果你想让体验更精致一些的话,鼠标悬浮在热区上的时候把 cursor 变成 pointer,拖拽的时候变成小手手,这可以做出一些体验上的区分度,也就多两行的事情,没有很麻烦。

至于开发上的麻烦事,你姑且听我给你念个经。

LLM 产出代码 QA 的最简单原则是,当你看到它针对某个功能反复搞出按下葫芦浮起瓢(反复回归)的破事时,你就要意识到它把架构搞烂了,这个时候如果你继续让它纯粹的修功能问题,屎山只会越堆越高。此时的当务之急是让它不要再堆屎重新思考架构修架构。LLM 开发会让你产出代码变快,但是它产出代码的品质没有比人类高很多,所以更快的代码产出意味着架构审查修复的频率也要变高。

移动端记得测,Chrome 的话随便找一个安卓机就可以搞。这个平台下面注意处理-webkit-tap-highlight-color,否则按下去之后整个 Canvas 会冒蓝光。记得处理触屏的特殊情况,很多浏览器会把触屏长按处理成鼠标悬浮,所以要根据输入设备在这块做一个特判。监听 Pointer Event,不要用 Mouse Event,否则移动端会出问题。记得拦截事件不要让它透下去,否则你搓擦的时候会触发底部内容拖蓝。除非你有特殊理由,否则在调用 scroll API 的时候尽量不要开 smooth,否则会有一狗票的数据同步问题。一定要加的话用 JS 写 Lerp 比较稳妥,因为我实际测的时候发现这里有一丢丢奇怪的 UB。

Safari 是你日常需要处理的麻烦事,没有苹果设备的话,至少装个 Gnome Web 测一下 Webkit 上有没有作什么幺蛾子。pointer 的座标用 TouchEvente.touches[0].clientX/Y,不要用外面的那个 e.clientX/Y。傻逼苹果在外面的值上面加了一大堆水跟面的魔法,导致你根本解不出稳定准确的触摸座标。你果立志在每一个 Web 标准上都注入自己的技术品味和烦人魔法,这已经不是一天两天的事情了。有时候我也在想为什么它们不直接搞个 W4C 或者 WTFWG,在现在的标准委员会里面呆着实在太对不起它们的创造力了。

现在你知道为什么造这玩意我花了两天,其中有半天是打开我的 M1 Mac Mini,登录果果帐号,下载 XCode,安装 iOS Simulator,等它缓慢启动,忍受卡到要死的模拟器,忍受卡得要死的设置界面,关了那卡得要死的 Liquid Ass,忍受动不动就连不进模拟器的 Safari 调试工具,一次次重启模拟手机。

你果不仅 Safari 整的稀烂,XCode 也烂的很均匀。五年前遇到的 Safari + XCode 的 Debugger Bug,至今依然硬朗健在,这对难兄难弟爱的结晶是如此的璀璨和宝贵,以至于我坚信它一定能陪伴我们读过下一个黄金五年。感谢你让我回忆起在回形针修苹果 bug 修到半夜、连续上班 28 小时的恐怖回忆,孩子已经倒在地上 PTSD 到抽搐了。

上述所有对苹果的羞辱全都是本人一字一字打出来的没有任何 AI 奇迹,当我面无表情地敲出这些恶毒的言语时,我的良心没有任何愧疚。

The Leap

到这里为止,都只是对浏览器原生滚动条的功能复现。不过都自己画了,还是可以在上面玩一点「设计师的小巧思」。我想到的玩法是直接把文章目录嵌入到进度条上。每一个标题都用小圆点表示,当你的鼠标悬浮在圆点上时,会弹出一张卡片告诉读者这个地方是什么标题。

在传统博客设计中,加不加 ToC 跟整个页面的版式设计有很大的关系。如果你的页面布局当中有侧边栏,那几乎就失去了可以塞文章目录快速跳转功能的空间。因为这东西得悬浮在一个固定的位置,「快速跳转」的功能才有意义。

而这个新做的滚动条几乎是一个完美的 ToC 容器,因为它天然地能够承载阅读进度显示、章节位置标示的功能。

考虑到敝博客近两年的文章都是大长文,标一大堆一级标题和二级标题会让人密集恐惧症发作,我决定只把一级标题筛出来挂在拖拽条的滑轨上。并且加了一个比较细微的动效,当你的鼠标从上方接近小圆点,左面的信息卡片会从上面沉下去,从下方接近小圆点,信息卡片会从下面浮起来。离开小圆点的时候也有类似的,符合物理直觉的逻辑。整个博客的动效系统原本就强调「如水母般自由而轻盈」的意向,在这里算是设计系统的自然延伸。值得一提的是,这里也做了 Reduce Motion 的支持,无论是因为晕动症还是想要省电,只要你在系统层面把动画关了,那么这个动画就不会播放,卡片会利落地出现、利落地消失。

Web 开发最麻烦的一点是你对用户窗口的尺寸没有一丝丝控制,比如用于缩小窗口的时候,所有小圆点的位置都会跟着变。垂直方向变大变小还好,圆点位置的变化总是流畅的。但如果是水平方向的变化,会导致页面高频重流,那一颗一颗的圆点会在原地高频震动。这不是你想看到的,所以记得给每个圆点的位置也加上 Lerp。这个细节会带来一点有趣的副产物。当你打开 Inspector 窗口瞬间缩小的时候,所有的圆点会错落有致地归位,颇有大珠小珠落玉盘之感。

很多开发者对于动效设计有非常浅薄的理解,认为它必须抓眼球。但动画本身是辅佐于某种功能的,比如避免视觉上的突兀变化影响原本的内容注意力设计,比如创造某种视觉引导,比如创造一种秩序感和意向。从内容设计的角度来讲,一组内容以一种「有秩序的不同」发生变化的时候,会让人感到流畅和舒爽。Thats how you make peoples brain happy.

接下来就是上亿点点小细节,当滑轨和小圆点交叠的时候,滑轨上会有一个挖孔,给小圆点留出更多空间,让它在视觉上可以被衬托出来。在这种情况下,小圆点也会显示出不同的颜色,这纯粹是为了方便色彩指定不需要在一大堆视觉冲突的条件里面不停的配平颜色。这个细微的颜色变化也带来了意外的视觉效果,因为颜色变化也有 LERP 渐变,所以在小圆点比较多的文章里面,你快速从上到下滑,小圆点们就会如流星拖尾一般一个个亮起再徐徐熄灭,看到这个副产物的时候我还有点小激动。

卡片设计上面有一个比较不好处理的细节,如果标题比较长的话需要折行。标题折行施加 text-wrap: balance 的魔法几乎是某种「国民义务」,但这个组合会让容器无法完全包裹内容,总会在边上留出一堆空白。这个情况是没办法用纯 CSS 处理的,必须得上一点点魔法。具体而言,你得用 document.createRange 这个 API 测量一下文字的外框,然后手动调整外框的宽度。

做到这里,实际上整个样式系统的复杂度就已经爆炸了。元素有圆点、滚动条;状态有鼠标离开滑轨、鼠标悬浮在滑轨上、鼠标悬浮在特定元素上;对于小圆点有一个当滚动条和圆点交叠的特殊情况;设置属性有颜色、透明度、尺寸,对于小圆点还有挖孔半径。再加一个鼠标离开热区三秒没有交互自动隐藏的透明度、位移动画,小圆点的位置、滑条的位置。上面所有的东西都要做事件处理、动画处理。可以说如果让人来写的话,那是非常没有人性了。别忘了还有亮色、暗色模式的问题,针对这两种情况色板都是需要单独设计的。

而且也不难发现,越基础的 UX 你得调的恼人细节越多,这也是为什么有经验的 Design Engineer 几乎不会推荐任何人徒手从零开始造基础组件(此处歌颂造 ToolTip 的猛士们)。滚动条这个例子有一点比较好的一点是你不需要考虑读屏器,但是做多级下拉菜单、传统鼠标交互组件的时候还得考虑读屏器,想想头就很大……

The Imagination

还有一些很好玩的东西可以做,但是我没有在本博客上做的东西。比如,你可以在滚动条边上加一个目录按钮,按下之后右侧自然浮出一层渐变阴影,并且把完整目录全都显示在滚动条边上。比如,如果你觉得哪个章节是重点,可以在小圆点上做一个涟漪动画。比如,你可以加入书签功能,划线标注的内容可以被同时标注在滚动条上。不难发现,当我们把一个游离于现有 DOM 系统之外的设计元素纳入 DOM 当中时,很多想想的空间就自然地生发出来了。

它本身不过是一根可粗可细可长可短没有感情的棒子,你想要给它加口味还是加螺纹都看你个人喜好。我把这根棒子的源码交给裙友后,他做的选择是 Reduce Motion 的情况下回退到原生滚动条,并且只在滑轨上标注页面当中的重点内容。我觉得这也是一个好的权衡,我很欣赏。

如果你问我要不要开源这根棒子,我目前给你的答案是我不是很想开源,一方面 QA 我还没做完,另外一方面可以预想地只要我把代码甩到网上,势必会出现一大堆同质化的视觉。但就像每个人都有自己性取向一样,每个人都应该有自己的棒子,你不能把别人的棒子拿来用。

你可能会觉得诧异,为什么这么上流的设计,介绍文章要写的这么下流。但一段健康的关系总是要有人在上面有人在下面,这样才会爽。

以上就是今天的下流文章,祝你读得开心。

(编注:本文没有使用 LLM 辅助写作,LLM 写不出此等下流的内容。)


  1. 如果你在操作系统层面把动画关了,那么我这边也会把识趣地不显示任何换页动画,这是刻意为之。 ↩︎

  2. 老母鸡在下完蛋之后会一边溜大侅一边狂叫。 ↩︎

  3. 每帧将当前值朝目标值移动一个固定比例,产生自然衰减过渡效果的动画技术。它的独特优势是目标值可以随时改变,动画会无缝重新收敛,而贝塞尔曲线一旦目标改变就必须重新计算整条路径。在需要频繁变换动画目标数值的场景当中很有用。除了进度条之外,鼠标动效也经常搞这一套,比如跟随鼠标跑的小球之类的。 ↩︎

  4. 这个问题是文章上线之后读者帮我测出来的,感谢读者。 ↩︎

Comments

Loading animation

Loading comments...