本文へスキップ
Articles

2026-08-20 公開 / Webツール

あとからダークモード対応するとき、最初に数えるべきは「変数を通っていない色」の数

87ページあるサイトを、後追いでダークモードに対応させました。色は前もって共通ファイルに変数としてまとめてあり、「あとは暗い値を入れるだけ」のつもりでした。実際には、そこを暗くした瞬間に25ページが読めなくなる状態でした。何を見落としていたのか、着手前に数えるべきだった数字、そして光モードを1行も変えずに入れる手順を書きます。

準備は「できている」ように見えた

このサイトは、配色を1つの共通CSSに集約してあります。各ページは自分の変数名からそこを参照し、読み込みに失敗しても壊れないようフォールバック値を書き添える形です。

/* 共通ファイル側 */
:root { --ks-accent: #267b7d; }

/* 各ページ側 */
:root { --accent: var(--ks-accent, #267b7d); }

この形なら、共通ファイルの --ks-accent を暗い値に差し替えるだけで全ページが追従するはずです。CSS変数は使う瞬間に解決されるので、間に1段挟まっていても伝わります。理屈は合っています。

ところが実際に測ってみると、この仕組みに乗っているページは全体の3割しかありませんでした。

着手前に数えるべきだった1行

やるべきだったのは、変数の設計を眺めることではなく、変数を通っていない色が各ページにいくつ残っているかを数えることでした。ページ内の <style> に直接書かれた色を数えるだけです。

python3 - <<'PY'
import re, glob
for f in sorted(glob.glob('**/*.html', recursive=True)):
    s = open(f, encoding='utf-8').read()
    st = ''.join(re.findall(r'<style[^>]*>(.*?)</style>', s, re.S))
    lit = len(re.findall(r'#[0-9a-fA-F]{3,8}\b|rgba?\(', st))
    var = len(re.findall(r'var\(--', st))
    if lit: print(f"{lit:4} 直値 / {var:4} 変数   {f}")
PY

結果はページの種類ではっきり割れました。

ツールのページは、機能ごとにその場で色を書き足してきた履歴がそのまま残っていました。ここで共通の変数だけを暗くすると、地は暗いのに、パネルもフォームも入力欄も明るいままという混ざった画面になります。文字が見えなくなる箇所も出ます。

つまり「共通ファイルを直せば全部変わる」は半分正しくて半分間違いでした。変わるのは共通ファイルを経由している色だけで、経由していない色は取り残される。そして取り残された色は、暗くなった地の上で最も目立つ場所に居座ります。

採らなかった案と、その理由

案1: 共通ファイルに直接ダークを書く

いちばん短い方法です。1ファイル直せば終わります。採りませんでした。この共通ファイルはツールのCSSからも読み込まれているので、書いた瞬間にツール25本が上記の混ざった状態になります。「後で直値を片付ける」を前提に先に暗くすると、その間ずっと壊れたページが公開されたままになります。

案2: 全ページの直値を先にトークン化してから、一気に暗くする

いちばん筋の良い方法で、最終的にはここへ向かうべきです。今回は採りませんでした。直値は全部で数百個あり、まとめて置き換えると差分がレビューできる量を超えます。しかも置換をミスした場合に壊れるのは今すでに正常に動いている光モードのほうです。ダークを入れるために光を壊す危険を負うのは、順序として間違っています。

採った案: ページ側から明示的に読み込む別ファイルにする

ダーク用のトークンを共通ファイルとは別のCSSに置き、安全だと確認できたページだけが読み込む形にしました。

<link rel="stylesheet" href="/assets/tokens.css">
<link rel="stylesheet" href="/assets/dark.css">  <!-- これを足したページだけ暗くなる -->

ツールのページはこの1行を持ちません。だから何も起きません。直値を片付けたページから1行ずつ足していけば、壊れる範囲がその1ページに限定されます。「全部を一度に正しくする」のをやめて、「一度に1ページだけ危険にする」に変えた、というのが判断の中身です。

共通のCSSファイルを持っていても、それを読んでいるページの範囲と、実際に色が乗っている範囲は別物です。読み込み関係だけを見て「全ページ対応済み」と思い込むと、今回のように3割しか効いていない状態に気づけません。

実際に効いた設計: 光モードを1行も変えない

今回の変更は、既存の宣言を書き換えるのではなく、末尾に追加するだけで構成しました。差分で言うと追加のみ、削除ゼロです。

/* 既存の宣言はそのまま。以下は純粋な追加分 */
@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;
    --bg:     #1a1917;
    --paper:  #23211d;
    --ink:    #ece9e0;
    --accent: #5fb5b6;
  }
}

こうすると、ダーク対応をやめたくなったらこのブロックを消すだけで完全に元に戻ります。光モードの表示は定義上変わりません。既に公開して使われているサイトに後から手を入れるとき、この「戻せる形」であることが、実際にはいちばん効きました。レビューも楽で、削除行がゼロなら「既存の見た目を壊していない」が読むだけで分かります。

color-scheme: dark は忘れやすい1行です。これを書くと、フォーム部品やスクロールバーといったブラウザが自前で描く部分も暗くなります。書かないと、自作の部分だけ暗くて入力欄だけ明るい、という中途半端な画面になります。

単純な反転では済まなかった3か所

1. 半透明の白は、暗い地では逆に働く

明るい地の上でカードをうっすら浮かせるために rgba(255,255,255,0.48) のような指定を使っていました。これは「白を混ぜて明るくする」指定です。暗い地で同じ値を使うと、そこだけ白っぽく浮き上がって、意図と正反対になります。

暗い地で同じ「うっすら浮いている」印象にするなら、不透明度を大きく下げる必要があります。実際には 0.480.05 まで落として、ようやく釣り合いました。10分の1です。感覚で調整すると必ず強すぎる側に外します。

2. アクセント色の上に置いた文字は、反転しないと読めなくなる

暗い地ではアクセント色そのものを明るくします。すると、そのアクセントを背景にしていたボタンの白文字が破綻します。実測するとこうなりました。

アクセントを明るくした分、その上の文字は逆に暗くする。ここは機械的に反転させる必要があります。見た目では気づきにくく、測って初めて分かりました。

3. 影は、黒を濃くしないと存在が消える

明るい地では rgba(0,0,0,.07) 程度の影で十分ですが、暗い地では地の色に沈んで完全に見えなくなります。カードの境界が消えて、のっぺりした画面になります。.40.55 くらいまで上げて、ようやく段差として認識できました。

詳細度で1回つまずいた

ダーク用のCSSを <link> で読み込み、そこにページ固有の固定色を打ち消すルールを書いたのに、効きませんでした。原因は読み込み順です。

<link rel="stylesheet" href="/assets/dark.css">   <!-- 先 -->
<style> p { color: #3f3a36; } </style>            <!-- 後。こちらが勝つ -->

ページ内の <style><link> より後ろにあります。詳細度が同じなら、後に書かれたほうが勝ちます。外部ファイルから打ち消すには、詳細度を1段だけ上げる必要がありました。

/* p { } では負ける。:root を前置して1段上げる */
:root p { color: var(--ink); }

!important でも通りますが、それをやると次にこの色を調整したい人が同じ手段を使うしかなくなります。1段だけ上げて済むなら、そちらのほうが後が楽です。

持ち帰れること

ちなみに私は1つめをやらずに始めました。共通ファイルが整っていたので「準備できている」と判断したのが間違いで、整っていたのは仕組みであって、それを使っているページの割合ではなかったという話です。数えるのは5分で済みました。