ClockHub アイコンの設計思想:16 ピクセルから逆算した文字盤
古い柱時計の文字盤。CC0、Wikimedia Commons より
![]()
この写真の要素は、ひとつもアイコンに残っていない。
唐草も、ローマ数字も、透かしもない。最終的に書き出したのは 2 種類の配色、それぞれ 14 ファイル、そのうち最小のものは 16×16 の正方形で、中身は 12 本の線と 2 本の針と赤い点がひとつだけ。
これは「ミニマルな作風」を選んだという話ではない。単に好みの問題なら、もう少し凝った形にしてもよかった。この図形を決めたのは、もっと硬い制約だった。16 ピクセルの中で見分けられなければならない。
だからこの記事はひらめきの話ではなく、逆算の話だ。16 ピクセルから遡って、各段階がどう追い込まれたのかを書く。
I. 正方形の中に目盛りはいくつ入るか
デザインのキャンバスは 512×512、設計半径は R=200。16 ピクセルに縮めると比率は 1:32。
つまりすべてを 32 で割る。
- 文字盤の設計直径 400 は、16 ピクセルでは 12.5 ピクセル
- 目盛りの外側の半径 190 は、5.94 ピクセル
- 文字盤の見える幅は、12 ピクセルに満たない
これが全予算だ。余りも、削れる余白もない。
では目盛りはいくつ入るか。円周を出せばわかる。半径 5.94 ピクセル、円周 37.3 ピクセル。
| 目盛りの数 | 隣り合う間隔 |
|---|---|
| 60 | 0.62 px |
| 24 | 1.55 px |
| 12 | 3.11 px |
![]()
いずれも 1.1 ピクセルまで太らせた状態。変数は目盛りの数だけ
チューリッヒ中央駅の有名なスイス鉄道時計は、文字盤に 60 本の目盛りを持つ。時刻用の太い 12 本と、分用の細い 48 本。このデザインの系譜の頂点だが、その 60 本を比率どおり 16 ピクセルに押し込むと、隙間が埋まって一本の輪になる。時計だとすらわからない。
ここから、この記事でいちばん大事な一文が出てくる。
線の太さは太らせられる。間隔は太らせられない。
幅 0.6 ピクセルの線は、1.1 にも 2 にも太らせられる。太らせても線は線のままだ。だが 2 本の線のあいだの 0.62 ピクセルという隙間は、どんな手段でも広げられない。あいだにあるものを取り除く以外には。
目盛りが 60 から 12 になったのは、12 が好きだからではない。16 ピクセルには 12 しか入らないからだ。
II. 引き算の一覧
形は一度で決まっていない。アイコンの調整ツールには 3 世代のパラメータが残っていて、変更した値はすべて scripts/icon/tuner.html に記録してある。
| 版 | 目盛り幅 | 針の幅 | 中心円 | 時針の角度 | 分針の角度 |
|---|---|---|---|---|---|
| 参考画像 | 15 | 16.5 | なし | 10° | 125° |
| 細い版(前バージョン) | 4.0 | 4.8 | r = 5 | 0° | 120° |
| 決定版 | 10 | 12 | r = 12 | 0° | 120° |
(長さと幅は半径 R に対する百分率。10 なら線幅は 200 の 10%、設計上 20 ピクセル。)
![]()
3 世代のパラメータと、それぞれの 16 ピクセルでの見え方
最初の版は参考画像どおりに作った。太い目盛り、太い針、中心円なし。大きなサイズでは重みがあったが、近くで見ると鈍い。そこで細い版を作った。目盛り 4.0、針 4.8、線はきれいで、中心円は r=5 まで絞った。3 つのうちでいちばん美しいのは細い版だ。
そしてそれは 16 ピクセルで消えた。
「見劣りする」のではない。そもそも描けない。細い版の目盛り幅 4.0 は、16 ピクセルでは 0.25 ピクセル。太らせる計算は 4.4 倍をはじき出すが、上限は 3.2 倍で止まる。目一杯太らせても 0.8 ピクセルで、下限の 1.1 ピクセルには届かない。
決定版は一歩戻した形だ。目盛り 10、針 12、中心円 r=12。大きなサイズでは細い版ほど優雅ではないが、16 ピクセルで「12 本の目盛りと 2 本の針」と読めるのはこれだけだった。
教訓はこうだ。美しさは、あるひとつのサイズにおける局所最適にすぎない。 判断は最小のサイズで下して、最大のサイズでの結果は受け入れるしかない。
III. 時刻を告げない文字盤
決定版の時針は 0°、分針は 120° を指している。この文字盤が示す時刻は 12:20。
そして 12:20 は実在しない時刻だ。本物の時計が 20 分を指すとき、時針はすでに 12 から三分の一だけ離れた 10° の位置にある。参考画像の版は 10° で、そちらが「正しい」。
時針を 0° に引き戻したのは、12 時の赤い目盛りと同じ半径に乗せるためだ。中心から上へ、白い針の下半分と赤い目盛りが一本の線になる。遠目には、先端だけ赤く染まった針に見える。
分針を 125° から 120° へ動かしたのも同じ理屈だ。0 も 120 も 30 の倍数なので、2 本の針先がちょうど目盛りの上に乗る。時針は 12 時の赤い目盛りに、分針は 4 時の目盛りに。参考画像の版は 2 本とも目盛りの間に止まっていた。10° は 12 時と 1 時のあいだ、125° は 4 時と 5 時のあいだ。
この 2 つの決定は、実は 16 ピクセルとは関係がない。 そのサイズでは、5° は分針の先を 0.35 ピクセル、10° は時針の先を 0.49 ピクセル動かすだけ——見えない。これは 220 ピクセル以上に向けた構図の決定だ。
だが方向は同じ。グリッドに合わせる。真実には合わせない。 12 本の目盛りがこの図形の唯一の座標系で、針先は目盛りの上に乗るか、目盛りのあいだに落ちるかしかない。前者は「設計された」に見え、後者は「適当に置かれた」に見える。
ついでに、時計広告で有名な 10:10 について。時針が 305°、分針が 60° なので、角度は実際には 115°。これも目盛りの上にはない。私たちはその角度を真似ていない。
代償として、この文字盤は実在の時刻を示さなくなった。
アイコンは時を告げない。 伝えるのは「これは時計だ」であって「今は 20 分過ぎだ」ではない。両者が衝突したとき、可読性が勝ち、正確さが負ける。
IV. 線一本の最小幅
0.625 ピクセルの話に戻る。
幅 0.625 ピクセルの線は、画面の上では細い線ではなく、濃さの違うピクセルの列になる。レンダラは隣り合う 2 ピクセルに半分ずつ配る。墨色であるべき目盛りが、淡い灰色のかすれになる。
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 ピクセルにするには何倍必要か」を問う。出た倍数を線幅と線長に掛け戻す。
10 個の出力サイズを全部通すとこうなる。
![]()
変更されるのは 16 と 24 の 2 つだけ。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.35 ではなく 0.4)。もともとほぼ正円なので、これ以上大きくすると 2 本の針の根元を食ってしまう。
このやり方は私たちが発明したものではない。Apple のヒューマンインターフェースガイドラインにははっきり書かれている。「極端に細い線や鋭い角は避けること。小さなアイコンサイズや低解像度では、細部と鮮明さを失いやすい。」Google はさらに進んで、これをフォントの軸にした。Material Symbols の opsz(オプティカルサイズ)は 20dp から 48dp まで動き、「異なるサイズで同じに見えるよう、線幅がサイズに応じて変化する」。
違いはただひとつ。彼らは何組も手で描く。私たちはコードに計算させる。
V. 2 つの下地
アイコンには外円がない。
これは好みではなく、空間の勘定だ。輪を描けば最低 2 ピクセルを食う。16 ピクセルの文字盤では、直径 12.5 ピクセルの 16% にあたる。外せば目盛りを外へ押し出せて、図形全体が一回り大きくなる。
だが外円を外すには代償がある。図形が自分の境界を失う。
輪のあるアイコンは、どんな背景の上でも「円」だ。輪がなくなると、宙に浮いた 12 本の線は既知の下地に寄りかかるしかない。板を敷くか、必ず白の上に来ることを保証するか。
そこで 2 つの配色になり、役割ははっきり分かれる。
![]()
同じ 2 本のタブバーに、それぞれのアイコンを載せたもの
- dark(黒板に白い針):
public/icons/dark/、ブラウザのタブ用。タブバーはブラウザの UI であり、アイコンは自分が明るいテーマに載るのか暗いテーマに載るのか知るすべがない。どちらでも通用する一案を選ぶしかない。黒い板は明るいタブバーでは明確な黒い正方形になり、暗いタブバーでも白い針が浮き上がる。 - 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 で、板を描かず、墨色の線をそのまま白いヘッダーに置く。同じ幾何、3 つの載せ方。濃い板、淡い板、板なし。
iOS 版にはもう一つある。透明にしてはいけない。 iOS はアイコン内の透明部分を黒で埋めるので、背景が透明なアイコンはホーム画面で黒い正方形になる。180 ピクセルの apple-touch-icon は板ごと描いている。宙に浮いた線の集まりではない。
VI. なぜスクリーンショットで済ませないのか
アイコンのスクリプトは Chrome を呼ばない。rsvg-convert も呼ばない。npm パッケージも入れていない。
自分で 2 つ実装している。ラスタライザと PNG エンコーダだ。
理由はスクリプト冒頭のコメントにある。
アイコンはピクセル単位で正確に、しかも毎回まったく同じに生成される必要がある。外部ツールの拡大縮小の方針、背景色、DPR はどれも結果に影響する。ここでは 4〜8 倍のスーパーサンプリングで自前でラスタライズし、Node 内蔵の zlib だけに依存する。別のマシンで走らせても PNG は 1 バイトも変わらない。
やり方はこうだ。まず 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。物々しく聞こえるが 100 行に満たない。見返りはサイズで、512×512 が 7 KB、16 ピクセルが 319 バイト。
本当の意味は依存を減らすことではない。アイコンをビルド成果物にすることだ。
コードが生成するアイコンは git に入り、差分が取れ、何度でも走らせられる。数字をひとつ変えれば、2 配色 28 ファイルがまとめて作り直される。デザイナーにもう一度 PNG を頼む必要も、書き出し時にチェックを入れ忘れたか心配する必要もない。アイコンは「一枚の画像」であることをやめ、「実行できる記述」になる。
VII. ひとつの JSON、ふたりの消費者
形の出どころはひとつだけ。src/constants/clock_icon.json。13 行、15 個の数値、1 個の真偽値、3 色。
{
"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"
}
これを読むものが 2 つある。
scripts/icon/generate.mjs—— 28 個の静的ファイルを生成するsrc/app/[lang]/components/clock_mark.tsx—— ヘッダーとフッターの動くロゴ
これがこの仕組みで最も目立たず、最も重要な決まりだ。静的なアイコンとページ上のロゴは食い違えない。 同じ数値から計算しているからだ。
ヘッダーの版はもうひとつ余計なことをする。回るのだ。
ポインタを乗せると 2 本の針が回り始める。分針は毎秒 840 度(約 2.3 回転)、時針は 1:12 のギア比で正確に追従する。離しても急停止せず、「静止角まであと何度回れば戻るか」を計算し、三次イージングで 0.7 秒かけて 12:20 に着地する。
ここには避けられない妥協がある。本物の時計のギア比は 1:12 で、分針が 360° 回るあいだに時針は 30° しか動かない。つまり正確な比率のままでは、2 本の針が同時に元の位置へ戻ることはない。分針が 120° に戻ったとき、時針は 0° にはいない。だから戻る 0.7 秒のあいだだけギアを切り離し、それぞれが最短経路で帰る。見た目にはわからないが、2 本とも正確に元の角度へ収まる。
もうひとつ。システムが「視差効果を減らす」(prefers-reduced-motion: reduce)を要求している場合、針はまったく回らない。
VIII. いちばん小さいインターフェース
16 ピクセルのアイコンは、作れるインターフェースのうちで最小のものだ。
ツールチップも、ホバー状態も、読み込み失敗時の再試行ボタンも、空状態もない。チャンスは一度きり、見分けられた一瞬だけ。誰もじっくり見ないし、覚えもしない。凝った作りだからといって半秒長く留まることもない。十数個のタブから、正しいものを探しているだけだ。
この制約が、装飾的な選択肢をすべて消す。残ったものは自力で立たなければならない。12 本の目盛りが「文字盤」を、2 本の針が「動いている」ことを、赤い点ひとつが「隣のアイコンとは違うもの」を伝える。
あの柱時計の作り手も、たぶん同じことを考えていた。相手は 16 ピクセルではなく直径 50 センチの真鍮板だったから、花を彫り、数字を切り、針を透かす余裕があった。こちらには 16 ピクセルしかなかったので、線を 12 本残した。
同じもの、違う予算。
この線がまともなサイズでどう見えるかは、アナログ時計が同じ幾何で動く文字盤を描いている。時刻をまるごと数字に渡すとどうなるかは、パタパタ時計が別方向からの答えになっている。