ClockHub
博客联系我们
全部文章
产品9 分钟阅读

ClockHub 图标的设计理念:从 16 像素倒推一个表盘

ClockHub

这张照片上的东西,我们的图标一个都没留。

一台老式座钟的表盘特写:罗马数字、錾刻的缠枝花纹、镂空的雕花指针,金属表面有一道反光

一台老式座钟的表盘。CC0,来自 Wikimedia Commons

没有花纹,没有罗马数字,没有镂空。最后交出去的是两套图,每套 14 个文件,最小的一份是 16×16 的方块——里面 12 根线、两根针、一个红点。

这不是「极简风格」的选择题。如果只谈审美,我们完全可以做得更精致一点。真正决定这个图形长什么样的,是一条很硬的约束:它必须在 16 个像素里被认出来。

所以这篇文章不讲灵感,讲推导:一个表盘图标从 16 像素倒着算回去,每一步是怎么被逼出来的。

I. 一个方块里放得下多少刻度

图标的设计画布是 512×512,设计半径 R=200。缩到 16 像素,比例是 1:32。

于是所有尺寸都要除以 32:

  • 表盘的设计直径 400,到 16 像素下只剩 12.5 像素
  • 刻度外沿的半径 190,只剩 5.94 像素
  • 也就是说,整个表盘的可见宽度,不到 12 个像素

这就是全部的可用空间。没有余量,没有留白可以再挤。

那刻度能放几根?算一下周长就知道了——半径 5.94 像素,周长 37.3 像素:

刻度数量相邻间距
600.62 px
241.55 px
123.11 px

三张按 16×16 像素真实渲染再放大 14 倍的表盘示意图:左图 60 个刻度糊成一个实心圆环,中图 24 个刻度连成一圈粗点,右图 12 个刻度清晰分离;三张图下方分别标注 0.62、1.55、3.11 像素的间距

同样加粗到 1.1 像素之后,只剩刻度数量这一个变量

苏黎世总站那口著名的瑞士铁路钟,表盘上是 60 个刻度:12 根粗的标小时,48 根细的标分钟。它是这个设计谱系里的天花板——但你把这 60 根刻度按比例压进 16 像素,它们会连成一个实心圆环,连「这是个钟」都看不出来。

到这里就能得到整篇文章最重要的一句话:

线宽可以加粗,间距加不粗。

一根 0.6 像素宽的线,你可以把它加粗到 1.1 像素、甚至 2 像素,它还是那根线。但两根线之间 0.62 像素的空隙,你没有任何办法让它变宽——除非拿掉中间的东西。

所以刻度从 60 减到 12,不是我们更喜欢 12,是 16 像素只放得下 12。

II. 减法清单

造型不是一次定下来的。图标调参器里留着三代参数,改过的每一项都记在 scripts/icon/tuner.html 里:

版本刻度宽指针宽中心圆时针角度分针角度
参考图1516.5无10°125°
细版(上一版)4.04.8r = 50°120°
定稿1012r = 120°120°

(长度和宽度都是「占半径 R 的百分比」,所以 10 表示线宽是 200 的 10%,即 20 个设计像素。)

三版表盘并排对比:左为参考图版本,刻度与指针最粗、没有中心圆;中为细版,线条极细、中心圆很小;右为定稿,粗细居中、中心圆明显;每张图下方各附一个 16 像素的缩略渲染

三代参数,以及它们各自在 16 像素下的样子

第一版是照着参考图做的:粗刻度、粗指针、没有中心圆。它在大尺寸下很有分量,但放大看会觉得「重」,于是有了细版——刻度 4.0、指针 4.8,线条干净,中心圆缩到 r=5。细版是所有版本里最好看的一个。

然后它在 16 像素下消失了。

不是「不够好看」,是根本画不出来。细版的刻度宽 4.0,16 像素下等于 0.25 个像素。加粗机制会把倍数算到 4.4 倍,但上限被钳在 3.2 倍——顶格加粗也只有 0.8 像素,还是没到 1.1 像素的下限。

定稿是往回退的一步:刻度 10、指针 12、中心圆 r=12。它在大尺寸下不如细版优雅,但它是唯一一个在 16 像素下还能读出「12 个刻度 + 两根针」的版本。

这一节的教训是:好看是一个尺寸下的局部最优。 你必须在最小的那个尺寸上做决定,然后接受大尺寸下的结果。

III. 不报时的表盘

定稿的时针指向 0°,分针指向 120°。这个表盘显示的时间是 12:20。

而 12:20 是一个假时间。真实时钟走到 20 分的时候,时针已经离开 12 点三分之一,也就是 10° 的位置。参考图那版就是 10°——它是「对」的。

把时针拉回 0°,是为了让它和 12 点那根红色刻度落在同一条半径上。从中心往上,白针的下半段和红刻度连成一根线,远看像一根针,上半截被染红了。

分针从 125° 挪到 120°,逻辑是一样的:0 和 120 都是 30 的整数倍,两根针的针尖都正好压在刻度上——时针压在 12 点那根红刻度,分针压在 4 点那根。参考图那版两根都停在刻度之间:10° 卡在 12 点和 1 点中间,125° 卡在 4 点和 5 点中间。

这两个决定其实和 16 像素没关系。 分针那 5° 在 16 像素下只挪动 0.35 个像素,时针的 10° 也不过 0.49 个——看不出来。它们是为 220 像素以上的尺寸做的构图决定。

但方向是一致的:对准网格,不对准真实。 12 根刻度是这套图形唯一的坐标系,针尖要么落在刻度上,要么落在刻度之间;前者读起来是「设计过的」,后者读起来是「随手摆的」。

顺手说一句钟表广告那个著名的 10:10:时针在 305°,分针在 60°,夹角其实是 115°——也不在刻度上。我们没照抄那个角度。

代价是这个表盘不再显示一个真实的时间。

图标不报时。 它要表达的不是「现在几点」,而是「这是个钟」。在这两件事冲突的时候,可读性赢了准确性。

IV. 一根线的最小宽度

回到 0.625 像素那个问题。

一根 0.625 像素宽的线,在屏幕上不是一个细线,是一排灰度不同的像素。渲染器会把它摊到相邻的两个像素上,各给一半的浓度。结果就是:本来该是墨色的刻度,变成了一道浅灰的雾。

scripts/icon/generate.mjs 里的处理办法,是把「最小线宽」直接写成一个数:

const MIN_PX = 1.1 // 刻度在输出像素里不能低于这个宽度
const MAX_BOOST = 3.2 // 但最多放大 3.2 倍,防止糊成一块

function boostFor(size) {
  const tickPx = ((SHAPE.tick.width / 100) * SHAPE.R * size) / CANVAS
  const k = MIN_PX / tickPx
  return Math.min(MAX_BOOST, Math.max(1, k))
}

逻辑是反的:不是「这个尺寸放大多少」,而是「要让线宽达到 1.1 像素,需要放大多少」。算出来的倍数再乘回线宽和线长。

十个输出尺寸全算一遍,结果是:

加粗系数曲线图:横轴为图标尺寸 16 到 512 像素(对数刻度),纵轴为放大倍数;曲线在 16 像素处为 1.76 倍、24 像素处为 1.17 倍,从 32 像素起精确等于 1.00 并一直保持水平

只有 16 和 24 两个尺寸被改动过,32 像素及以上完全没动

只有 16 和 24 被改过。 32 像素时线宽已经是 1.25 像素,超过下限,倍数精确等于 1.00——代码什么都没做。这是这套机制最好的地方:它是一个兜底,不是一个风格。

还有一个容易忽略的细节:加粗的时候,长度要跟着长。

const grow = 1 + (k - 1) * 0.35 // 长度加长,避免加粗后变成圆点
const hubGrow = 1 + (k - 1) * 0.4 // 中心圆放大得更保守

因为所有线条的端点是圆头(stroke-linecap: round)。一根长度 48、宽度 20 的刻度,加粗到宽度 35 而长度不变,它就不再是刻度,是一个胶囊;再粗一点,就是一个圆点。所以宽度乘 1.76 的时候,长度乘 1.27——不到一半的比例,刚好维持住「细长的条」这个形状。

中心圆更保守(0.4 而不是 0.35),因为它本来就接近正圆,再放大会把两根针的根部吃掉。

这套做法不是我们发明的。Apple 的人机界面指南里明确写着:「避免极细的线条和尖锐的角,它们在小尺寸、低分辨率下会丢失细节和清晰度。」Google 走得更远,把这件事做成了字体的一条轴——Material Symbols 的 opsz(optical size,光学尺寸)参数从 20dp 到 48dp,「为了让图标在不同尺寸下看起来一样,线宽要随尺寸变化」。

区别只是:他们是手动画几套,我们是让代码算。

V. 两种底色

图标没有外圈。

这不是造型偏好,是空间账:一个圆环至少要 2 个像素的宽度,在 16 像素的表盘上,那就是 12.5 像素直径里的 16%。去掉它,刻度能往外多推一点,整个图形能大一圈。

但去掉外圈有代价:图形失去了自己的边界。

一个带外圈的图标,放在任何背景上都是「一个圆」。没有外圈之后,12 根悬空的线必须依附在某个已知的底色上——要么给它一块底板,要么确保它落的地方一定是白的。

于是有了两套配色,分工是明确的:

两行模拟浏览器标签栏的对比图:第一行用深色图标,在浅色和深色标签栏上都清晰可辨;第二行用浅色图标,在浅色标签栏上白色底板消失、只剩几根淡线,在深色标签栏上则很清晰

同两条标签栏,两种图标

  • dark(黑底白针):public/icons/dark/,用在浏览器标签页。因为标签栏是浏览器的界面,图标没办法知道自己落在浅色还是深色的标签栏上,必须选一个两种情况下都能看见的方案——深色底板在浅色标签栏上是一个明确的深色方块,在深色标签栏上白针依然跳出来。
  • light(白底黑针):public/icons/light/,用在站内页头、OG 图、iOS 主屏。这些地方的底色是我们自己控制的。

src/lang/seo.ts 里那一行就是这个决定:

icons: { icon: '/icons/dark/favicon.ico', apple: '/icons/light/icon.svg' }

favicon 取 dark,apple-touch-icon 取 light。前者要独自面对未知背景,后者落在 iOS 主屏的浅色壁纸上。

站内页头用的是第三种形态:clock_mark.tsx 里 plate 默认 false,不画底板,直接用墨色线条画在白色页头上。同一份几何,三种承载方式:深色底板、浅色底板、没有底板。

iOS 那一版还多一条:不能透明。iOS 会把图标里的透明区域填成黑色,一个透明背景的图标上了主屏就是一个黑方块。所以 180 像素的 apple-touch-icon 是连底板一起渲染的,不是一圈悬空的线。

VI. 为什么不用截图工具

生成这些图标的脚本,没有调用 Chrome,没有调用 rsvg-convert,没有装任何 npm 包。

它自己实现了两件事:一个光栅化器,和一个 PNG 编码器。

理由写在脚本开头的注释里:

图标要像素级精确且每次生成完全一致,外部工具的缩放策略、背景色、DPR 都会影响结果。这里用 4~8 倍超采样自己栅格化,只依赖 Node 内置的 zlib,换台机器跑出来的 PNG 一字节不差。

具体做法是:先把图形按 4 到 8 倍的分辨率采样(16 像素的图标采样到 128×128),每个子采样点判断落在哪个形状里,得到颜色;然后按 8×8 的块做盒式平均,缩回 16×16。这样抗锯齿是算出来的,不是靠渲染器的启发式。

const ss = Math.max(2, Math.min(8, Math.ceil(2048 / size)))

小图采得多(16 像素用 8 倍),大图采得少(512 像素用 4 倍)——因为绝对采样密度大致固定在 2048 这一档。

编码器也是手写的:PNG 的 IHDR / IDAT / IEND 三个块加 CRC32,ICO 的 ICONDIR 加内嵌 PNG。听起来吓人,实际上不到一百行。回报是体积——512×512 的图标 7 KB,16 像素的 319 字节。

这件事真正的意义不是省了几个依赖,是让图标变成构建产物。

一个由代码生成的图标,可以进 git、可以 diff、可以重跑。改一个数字,两套 28 个文件全部重新生成,不需要找设计师要一遍 PNG,也不用担心某次导出忘了勾某个选项。图标不再是「一张图」,是「一段可以被执行的描述」。

VII. 一份 JSON,两个消费者

形状参数只有一份来源:src/constants/clock_icon.json。13 行,15 个数字,一个布尔值,三个颜色。

{
  "canvas": 512,
  "R": 200,
  "plateRadius": 0.16,
  "tick": { "len": 24, "width": 10 },
  "redTick": { "len": 35, "width": 12 },
  "hour": { "len": 45, "width": 12, "angle": 0 },
  "minute": { "len": 65, "width": 10, "angle": 120 },
  "hub": { "r": 12, "width": 3, "fill": true },
  "ink": "#111827",
  "paper": "#ffffff",
  "red": "#ef4444"
}

读它的有两处:

  1. scripts/icon/generate.mjs —— 生成 28 个静态文件
  2. src/app/[lang]/components/clock_mark.tsx —— 页头和页脚那个会动的 logo

这是整套设计里最不显眼、但最重要的一条:静态图标和页面里的 logo 不可能长得不一样,因为它们算的是同一组数。

页头那个版本还多做了一件事——它能转。

鼠标移上去,两根针开始旋转:分针每秒 840 度(大约 2.3 圈),时针严格按 1:12 的齿轮比跟着走。移开之后不硬停,而是算出「再转过多少度能回到静止角」,用三次缓出把这一段走完,0.7 秒落回 12:20。

这里有一个必须妥协的地方。真实钟表的齿轮比是 1:12,分针转 360°,时针只走 30°。这意味着按真实比例转,两根针永远回不到各自的起始位置——分针回到 120° 的时候,时针一定不在 0°。所以回正这 0.7 秒里,齿轮比是松开的:两根针各自走最短路径回去。看不出来,但两根针都能精确停回原位。

还有一条:如果系统开了「减少动态效果」(prefers-reduced-motion: reduce),指针根本不转。

VIII. 最小的界面

一个 16 像素的图标,是你能做的最小的界面。

它没有 tooltip,没有悬停态,没有加载失败的重试按钮,没有空状态。它只有一个机会,就是被认出来的那一眼。用户不会盯着它看,不会学习它,不会因为它设计得精巧而多停留半秒——他只是在十几个标签页里找那个能点对的东西。

这个约束把所有装饰性的选择都删掉了。留下来的东西必须自己站得住:12 根刻度决定它是钟面,两根针决定它在走,一个红点决定它和旁边那个图标不是同一个。

那台老座钟的作者大概也是这么想的。他面对的不是 16 个像素,而是一个直径半米的铜盘,所以他有资格往上刻花、刻数字、把指针镂空。我们面对的是 16 个像素,所以只能留 12 根线。

同一个东西,不同的预算。

想看看这些线条在正常尺寸下长什么样,指针时钟会用同一套几何画一个能走的大表盘;想看看把时间彻底交给数字会怎样,翻页时钟是另一个方向的答案。

ClockHub