跳到主要内容

给「暮色」做一套设计系统

从一个十六进制色值开始,把温柔和克制变成可以被代码执行的规则。

目录

这套设计系统从来没有发布过,也没有版本号。它只是这个站点背后的那一层秩序——我在两年里一点一点改出来,改到后来,它开始反过来替我做事。

把它写下来,是因为真正难的部分,和我一开始以为的并不在同一个地方。

一、从四十多个文件里改一个灰色

最开始我把十六进制直接写进组件:边框 #E5E0D8,正文 #2B2A28,背景 #F5F1EA。这样写很快,看起来也完全没有问题——颜色就是颜色,何必再绕一层?

半年后我想加一套暗色模式,才发现自己给自己修了一座迷宫。同一个灰色散落在四十多个文件里,有大写也有小写,还有一处被我手抖写成了 #E4E0D8,谁都没有发现,包括我自己。

更麻烦的是,我分不清哪些灰色是「同一个」,哪些只是「看起来像」。改颜色的时候,我不像在设计,更像在考古。

那一晚我做了一件很笨的事:把全站的颜色一条条找出来,列成一张表,然后盯着它看了很久。表上其实只有十一种颜色,但在代码里,它们有一百多个名字。

二、令牌命名的不是颜色,而是意图

于是颜色被收敛成语义令牌:--bg、--surface、--text、--muted、--border、--accent。

关键不在于数量变少了,而在于它们开始回答另一个问题。它们不再说「这是什么颜色」,而是说「这里要用来做什么」。

一块颜色叫什么,应该由它承担的职责决定,而不是由它在色板上长得像什么决定。

这个转变的好处,在我第一次换强调色的时候立刻显现:需要动的地方从一个下午变成了两行。

也是从那时起,组件里不再出现任何写死的色值。不是为了洁癖,而是因为我终于明白:把决定权收在一个地方,将来才有改的余地。

三、两套色板,和一条拒绝彩虹的规则

亮色的底是旧纸白 #F5F1EA,不是纯白。纯白太干净了,干净得没有时间感,像刚拆封的纸。旧纸白带一点点黄,是你反复展开过的那张信纸的颜色。

暗色的底是夜蓝黑 #0E1018,不是纯黑。纯黑会给每个字切出一圈硬边,看久了眼睛发酸;留一点蓝,暗处看起来才像夜里,而不像屏幕被关掉了。

强调色只有两个:亮色下的暮橘 #C96F5A,暗色下的 #E08B72。后者要亮一些,因为深色背景会吃掉颜色的重量,也会吃掉它的温度。

渐变只做同色系深浅。黄昏那一条是 #0E1018 → #2A1F3D → #4A2C3A:从夜走进紫,再回到一点残留的暖,像天色一点点让出来的次序。

我不做彩虹渐变。不是因为它不好看,而是因为它太响了——在一个安静的页面里,它会成为唯一被人记住的东西。而我想被记住的,是内容。

四、把时间也变成令牌

动效真正的问题从来不是快慢,而是每一处都不一样:有的滑进来,有的弹进来,有的淡进来,像三个人各自装修了同一个房间。

后来所有曲线统一成一条 cubic-bezier(0.22, 1, 0.36, 1)。它先快后缓,收尾绵长,在最后那一小段里还在轻微地移动。像黄昏——天不是一下子黑下去的。

时长也分成两档:微交互 250–350ms,入场 800–1200ms。前者要让人确信「点到了」,所以必须快;后者要让人安静下来,所以必须慢。中间那些五百毫秒左右、说不清在表达什么的动画,全部删掉。

底下还有一条硬规则:只动 transform 与 opacity。它们不触发重排与重绘,也不会在旧手机上掉帧。好看的前提,是它得先跑得动。

五、最难的是决定不做什么

删除比添加难,因为添加有即时回报,而删除的回报要等到很久以后。

我试过让按钮弹跳。它确实可爱,前三次点击也很愉快;但第四次开始,它就变成了噪音。被它打扰的人,恰好是那些只想安静读完一篇文章的人。

所以有了这张清单:不做弹跳,不做旋转,不做闪烁,不做会忽然放大的悬停;移动端关掉视差和鼠标跟随——在没有鼠标的地方,跟随只是一种延迟。

克制不是少做一点,而是为了那件真正重要的事,把其余的都让开。

这句话听上去像自我安慰。但每一次忍住不加,后来的我都会感谢当时的我。

六、中英混排,是排版里真正难的部分

这个站是双语的,同一套样式要同时容得下中文和英文,而它们对「舒服」的定义并不一样。

汉字是方块,密度大,需要放松:行高 1.8,行宽 36–40em。压到 1.5 就会显得拥挤,读两三行就想移开眼睛。

英文靠小写字母的轮廓与词间空隙呼吸,行高 1.6 就够,但行宽要到 65–75ch。短了会不断折行,读起来像一边下楼一边看书。

字体也分开:中文标题用霞鹜文楷,笔画里有手写的松弛,能让标题不那么像一则公告;英文标题用 Newsreader,衬线很细,气质安静。

同一段话,在两种语言里需要两套呼吸的节奏。这件事没有办法靠一个数值解决,只能分别照顾。

七、一次失败:把 max-w-[20ch] 加在容器上

这是我最想写下来的一段,因为它错得非常具体。

为了不让大标题一行拖得太长,我在包裹它的容器上加了 max-w-[20ch]。理由听上去很简单:限制在二十个字左右。

结果标题变成了一座每行只有一个字的高塔。

原因是我忘了 ch 是相对单位——它按元素自身的字号计算。容器是 17px 的正文尺寸,20ch 算下来不到 400px;而里面的大标题是 44px,宽度自然只够放下一个字。

正确的做法,是把宽度约束加在真正承载大字的那一个元素上,让它按自己的字号去算。

宽度约束要加在真正承载大字的那个元素上。

这个 bug 教给我的不只是 ch 的定义,而是一种更通用的直觉:约束要写在它所约束的东西身上。

八、尊重 prefers-reduced-motion 是底线,不是加分项

有人会因为前庭功能的原因,被动效弄到头晕、恶心。这不是偏好问题,是身体反应。

所以在 prefers-reduced-motion 生效时,我会把入场动画全部关掉,只留下一点透明度的变化——让内容出现,而不是让内容动起来。

它不应该被算作加分项。一个页面如果必须动才能用,那它本来就没有想清楚。

这套东西没有发布,没有版本号,也没有文档,只有几个还带着注释的文件。它真正留下的是一种习惯:当某样东西需要改变时,有唯一的一个地方可以改。

这就是全部的回报。不是规则的数量——大部分规则我早就忘了——而是下一次改动比上一次便宜。

暮色听起来很轻,但让它稳住的东西一点都不浪漫:一组名字取得足够无聊的变量,一条从来没被改过的曲线,和一张写着「不做」的清单。

天不会一下子黑下去,页面也不应该。

分享X / Twitter
你可能也想读

你可能也想读

评论3

  1. 夜航船2026.09.14

    写得真好,尤其是决定不做什么那一段。

  2. 夜航船2026.09.14

    决定不做什么那一段,我读了两遍。

  3. 路过的人2026.09.14

    缓存失效测试评论。

写评论

不会公开
选填,审核通过后会显示在称呼旁边
评论已提交,审核后显示。
订阅收信

喜欢的话,可以订阅收信

大约每月 1–2 封 · 没有广告,也没有转发 · 只用来发信,不会分享给任何人。

联系我

说说你在做什么,以及需要什么帮助。

通常 1–2 个工作日内回复