社内向けのWebページ数枚に、ダークモードの切り替えボタンを付けました。実装は Claude Code に任せ、私は「最初はOSの設定に合わせて、ボタンで選んだらそれを覚えておく」という動きだけ決めました。
やってみると、コード自体は短いのに、つまずく場所がいくつもありました。この記事では、HTML・CSS・JavaScript だけで作る手順と、実際に踏んだ落とし穴をまとめます。
テーマの決め方は3段階
ページの色は、次の優先順で決めます。
-
ボタンで選んだ値:
localStorageにlight/darkが保存されていればそれを使う -
OSの設定:保存値が無ければ
prefers-color-schemeで判定する - 既定値:どちらも取れなければライト
決まった値は <html> の data-theme 属性に書きます。初期表示・ボタン・OS設定の変更という3つの入口が、すべて「この属性を書き換える」だけで済むのがポイントです。
localStorage はオリジン(プロトコル・ドメイン・ポートの組)単位で分かれます。同じドメインのページどうしで保存キーを揃えれば、1ページで選んだテーマが他のページにも効きます。別ドメインのページは共有できないので、別キーで独立させました。
ちらつき対策:<head> の先頭でテーマを決める
一番目立つ不具合は、読み込みの一瞬だけライト配色が見える「ちらつき」です。テーマを決めるスクリプトが描画より後に動くと起きます。
対策は、<head> の先頭、CSS の <link> より前に、インラインで次のスクリプトを置くことです。
<script>
(function () {
var t = null;
try { t = localStorage.getItem('site-theme'); } catch (e) {}
if (t !== 'light' && t !== 'dark') {
t = (window.matchMedia &&
matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
document.documentElement.dataset.theme = t;
})();
</script>
-
外部ファイルにしない:インラインの通常の
<script>は実行が終わるまで描画を待たせます。その性質をあえて使っています -
書き込み先は
<html>:この時点では<body>がまだ存在しません -
localStorageはtryで囲む:プライバシー設定によってはアクセスだけで例外が出ます -
保存値は
light/darkと厳密比較:想定外の値が入っていたらOS設定に戻します
配色は CSS 変数で切り替える
:root {
color-scheme: light;
--paper: #FFFFFF; /* カード背景 */
--neutral: #F7F8FA; /* ページ背景 */
--ink: #1C1C1E; /* 本文 */
--rule: #E2E5EA; /* 罫線 */
}
@media screen {
:root[data-theme="dark"] {
color-scheme: dark;
--paper: #1D2128;
--neutral: #14171C;
--ink: #E6E8EB;
--rule: #30353D;
}
}
color-scheme: dark を入れておくと、スクロールバーや入力欄などブラウザが描く部品もダーク用になります。
ダーク用ルールを @media screen で囲んでいるのは印刷対策です。ダーク表示のまま印刷すると、暗い背景でインクを大量に使うか、背景が省かれて白い紙に明るい文字が残るかのどちらかになりがちです。screen で囲っておけば、印刷時は自動的にライト配色になります。
落とし穴1:1つの変数が「文字色」と「下地」を兼ねていた
切り替えてみると、読めない部品がいくつか出ました。原因はどれも同じで、1つの変数を2つの役割で使っていたことです。
濃い紺の --primary を、見出しの文字色にも、白文字を載せるバッジの背景にも使っていました。ダークで --primary を明るい水色にすると、見出しは読みやすくなる一方、バッジは「水色の上に白文字」で読めなくなります。
@media screen {
:root[data-theme="dark"] { --primary: #8FB3E0; }
:root[data-theme="dark"] .badge,
:root[data-theme="dark"] .to-top { background: #2F5A8C; }
}
文字用の変数はダークで明るくし、白文字を載せる部品だけ個別に濃い色を当てました。変数ごとに color: と background: の両方で使われている箇所を検索すると、兼用している変数をすぐ洗い出せます。
落とし穴2:ダークで hover が効かない
.btn-delete { background: #FDECEA; }
.btn-delete:hover { background: #F9D2CE; }
:root[data-theme="dark"] .btn-delete { background: #3A1F1E; }
こう書くと、ダーク時に hover しても色が変わりません。:root[data-theme="dark"] .btn-delete の詳細度が .btn-delete:hover より高いためです。ダーク用の hover も同じ強さで書き足します。
:root[data-theme="dark"] .btn-delete:hover { background: #5A2A28; }
ダーク用ルールを足した部品は、:hover / :active / :disabled などの状態違いも確認が必要です。
落とし穴3:プレースホルダーとフォーカス枠
-
input::placeholderは既定色だと暗い背景で薄すぎることがあります。色を指定し、ブラウザによって半透明なのでopacity: 1も付けます -
:focus-visibleの枠がライト用の濃い色のままだと、暗い背景ではほぼ見えません
どちらもマウスで眺めているだけでは気づきません。Tab キーで移動してみる、空の入力欄を見る、を一度やると見つかります。
切り替えボタン
ボタンは <button id="theme-toggle"></button> を置くだけにして、ページ末尾のスクリプトで制御します。
(function () {
var KEY = 'site-theme', root = document.documentElement;
var btn = document.getElementById('theme-toggle');
if (!btn) return;
function saved() {
try {
var v = localStorage.getItem(KEY);
return (v === 'light' || v === 'dark') ? v : null;
} catch (e) { return null; }
}
function paint() {
var dark = root.dataset.theme === 'dark';
btn.textContent = dark ? 'ライトにする' : 'ダークにする';
}
btn.addEventListener('click', function () {
var next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
try { localStorage.setItem(KEY, next); } catch (e) {}
paint();
});
if (window.matchMedia) {
var mq = matchMedia('(prefers-color-scheme: dark)');
function follow(e) {
if (!saved()) {
root.dataset.theme = e.matches ? 'dark' : 'light';
paint();
}
}
if (mq.addEventListener) mq.addEventListener('change', follow);
else if (mq.addListener) mq.addListener(follow);
}
paint();
})();
- ボタンの文字は「押すと何になるか」を表示します(今ダークなら「ライトにする」)
- 実際には
aria-labelも同じタイミングで更新しています - OS設定への追従は保存値が無いときだけ。一度選んだ人の選択を勝手に上書きしないためです
-
addListenerは Safari 14 未満向けの予備です
「OSに合わせる」状態へ戻すボタンは付けませんでした。1つのボタンで3状態を回すと、押すたびに何が起きるか分かりにくくなるからです。
動作確認のチェックリスト
- OSをダークにして初めて開く → 最初からダークで、ちらつかないか
- ライトに切り替えて再読み込み → ライトのままか
- 同じサイトの別ページ → キー共有ならそちらもライトか
- ライト配色が、ダーク対応前と同じ見た目か
- スマホ幅でボタンが崩れないか
- 印刷プレビューがライトか
最後に、確認中に保存された値を localStorage.removeItem('site-theme') で消しておきます。これを忘れると、あとでOS追従を確かめたときに自分の古い選択が優先され、「追従しない」と誤解します。
なお、ちらつきの有無は AI 側からは見えません(ページを読み取るのが描画完了後のため)。そこはコードの並び(スクリプトが CSS より前・インライン)で確認し、目視は人間側で行いました。
まとめ
ダークモード対応は色を反転するだけの作業に見えて、実際は「同じ色がどこで何の役割を担っていたか」の棚卸しでした。ライトでは問題にならなかった兼用が、反転した瞬間に表に出ます。付ける前に変数の使われ方を一度眺めておくと、手戻りがかなり減ります。
元になった記事(AI側の視点で書いたもの): https://kujiragames.com/2026/09/dark-mode-toggle/