本サイトに light-dark() でダークモード対応してみた
夜にコーディングしながら自分のポートフォリオを開いたら、画面の眩しさで目が痛くなりました。それがきっかけで、AoiWeb(このサイト)をダークモードに対応させました。
使ったのは light-dark() というCSSの関数です。ライトとダークの色を1行で書き分けられて、メディアクエリを書くより見通しがいい。ただ、いきなりこの関数だけで済むわけではなく、土台の整備にかなり手間をかけました。
僕が進めた順番をそのまま書くので、同じようにダーク対応を考えている方の設計の参考になればと思います。
完成形:OSの外観設定に追従する
先に結果を出します。AoiWebのトップページは、OSの外観設定(ライト・ダーク)に合わせて、自動で色が切り替わるようになりました。


背景の青はほぼ反転し、モックアップのカードもダーク基調になりました。文字色やリンクの強調色も、背景に合わせて読みやすい色に調整しています。
ダークモードがあるだけで、サイト全体の雰囲気がガラッと変わりますね、、!時間帯や気分で切り替えられるのは、見ている側にとっても嬉しいポイントだと思います。
設計の方針:いきなり light-dark() から始めない
今回いちばん気をつけたのは、段階的に進めることです。既存のサイトに後からダークモードを足すとき、いきなり light-dark() を書き始めるとほぼ確実に色が崩れます。原因は、既存のCSSに散らばった色指定が 「具体的な色」のまま固定 されているからです。
- Phase 1:色をSCSS変数からCSSカスタムプロパティに直す(土台作り)
- Phase 2:各SCSSファイルを
var()経由に書き換える(参照の置き換え) - Phase 3:「役割」で命名する Semantic 層を挟み、
light-dark()でダーク対応する(本番)
地味な作業ですが、Phase 1と2は先に終わらせておきます。すると Phase 3 で色を切り替える作業は、「変数の右辺を書き換えるだけ」の単純作業になります。コミットも細かく分けたので、途中で壊れてもすぐ戻せました。
Phase 1:色をカスタムプロパティとして定義する
もともとAoiWebの色は、Sass(CSSを拡張するメタ言語)で管理していました。使っていたのは $color-list という連想配列です。これだと色を変えたいときコンパイルし直しが必要で、ブラウザ上で色を切り替えるダークモードとは相性が悪いです。
そこで _root.scss で :root セレクタを開き、Sassの @each ループを使いました。$color-list を全部CSSカスタムプロパティに展開します。
:root {
@each $name, $value in $color-list {
--color-#{$name}: #{$value};
}
}
これで --color-main などのCSSカスタムプロパティが使えるようになりました。ブラウザ側から直接参照できます。この段階ではまだ色は何も変わっていません。土台を作っただけです。
Phase 2:既存ファイルを var() 経由に置き換える
次に、既存のSCSSファイルの $color-main のような Sass 変数の直接参照を書き換えました。すべて var(--color-main) というCSSカスタムプロパティ経由の参照にします。
// Before
.btn {
color: $color-main;
background-color: $color-bg-soft;
}
// After
.btn {
color: var(--color-main);
background-color: var(--color-bg-soft);
}
地味な作業ですが、ここを丁寧にやらないとダーク対応のときに置き換え漏れが起きます。AoiWebでは foundation/、layout/、module/、page/ の順に進めました。ディレクトリごとにコミットを分けています。
1つのコミットで20ファイルを一気に書き換えるのではなく、6回に分けて少しずつコミット。途中で表示崩れがあっても、どのコミットが原因かすぐ分かります。
Phase 3-1:「役割」で命名する Semantic 層を挟む
ここからが本番です。--color-main や --color-text-body は 「具体的な色」を表す名前 です。これだとライト用の値しか持てません。
そこで、--color-* を直接参照するのをやめて、「役割」を表す Semantic 層 を間に挟みます。背景なのか、前景なのか、ボーダーなのか、リンク色なのか。役割で命名したカスタムプロパティを新しく定義します。
:root {
--foreground-base: var(--color-text-body);
--foreground-heading: var(--color-text-heading);
--foreground-muted: var(--color-text-meta);
--background-base: var(--color-brightest);
--background-soft: var(--color-bg-soft);
--border-base: var(--color-border-light);
--accent-link: var(--color-link);
}
名前のつけ方は、Tailwind CSS や shadcn/ui の語彙を参考にしました。foreground / background / border / accent といった呼び方です。「黒(具体)」ではなく「文章の前景色(役割)」と呼ぶことで、後からダーク用の色を別途渡しても破綻しません。
この時点でも色はまだ変わっていません。間に層を1枚挟んだだけです。
Phase 3-2:light-dark() でダークモード対応する
いよいよダークモード対応です。手を動かした内容は次の通りです。
color-scheme: light darkを:rootに宣言する- ダーク用の Primitive(
--color-dark-*)を定義する - Semantic 変数の右辺を
light-dark()で巻き直す
color-scheme を宣言することで、ブラウザに「このページはライトとダーク両方に対応している」と伝えます。これがないと light-dark() 関数自体が機能しません。
続いて、ダーク用の色を別の連想配列にまとめておきます。
$color-dark-list: (
bg-base: #0d1a2a,
bg-soft: #1e2f44,
fg-base: #c8d2dc,
fg-heading: #e8f4fd,
fg-muted: #8892a0,
border-base: #2a3a4d,
link: #69f,
);
そしてこれを Phase 1 と同じように --color-dark-* として展開します。Semantic 層の右辺を light-dark() で書き直します。
:root {
color-scheme: light dark;
--foreground-base:
light-dark(var(--color-text-body), var(--color-dark-fg-base));
--background-base:
light-dark(var(--color-brightest), var(--color-dark-bg-base));
--border-base:
light-dark(var(--color-border-light), var(--color-dark-border-base));
--accent-link:
light-dark(var(--color-link), var(--color-dark-link));
}
light-dark(明色, 暗色) は、OSの外観設定がライトなら第1引数、ダークなら第2引数を返す 関数です。これだけで prefers-color-scheme のメディアクエリを書かずに済みます。

ハマった話:body の参照を見落として全部ライト固定だった
ここまでやって「よし、確認してみるか」とOSをダークモードに切り替えたところ、背景が真っ白なままでした。文字色は変わるのに、画面全体は明るいまま。なぜか。
原因は foundation/_base.scss でした。body 要素の color と background-color が、Semantic 層を経由していませんでした。Primitive を直接参照したままだったんです。
// 直っていなかった箇所
body {
color: var(--color-text-body);
background-color: var(--color-brightest);
}
Phase 2 で var() 経由には変換していました。ただ Phase 3-1 で Semantic 層を作ったときに、body だけ放置していたんです。
// 修正後
body {
color: var(--foreground-base);
background-color: var(--background-base);
}
これで一気にダーク対応が効くようになりました!ページ全体の色を司る body ほど見落としやすい、というのが今回いちばんの学びです。
僕は確認するとき、まず「bodyの参照が Semantic に向いているか」を見るようにしています。
Phase 3-3:oklch() 相対色構文で派生色を作る
ここまででダーク対応は機能しています。が、もう一歩踏み込んだのが Phase 3-3 です。
ボタンには通常色のほかに、hover や active の派生色が必要です。ライト時は元色より少し暗く、ダーク時は元色より少し明るくしたい。これを oklch()(人間の視覚に近い色空間)の相対色構文で書きます。
:root {
--background-button-active: light-dark(
oklch(from var(--color-main) calc(l - 0.13) c h),
oklch(from var(--color-main) calc(l + 0.06) c h)
);
}
これは「--color-main の明度を 0.13 下げた色」という意味です。彩度(c)と色相(h)はそのまま使い回します。
oklch() の相対色構文がピンと来なくても、深く理解せずにそのままコピーして進めて問題ありません。
これを light-dark() と組み合わせると、派生色も1つの変数定義で両対応できます。ライト時は元色を暗く、ダーク時は元色を明るくする指定です。色を1色ずつ手動でカラーピッカーで合わせる必要がなくなり、ブランドカラーが変わっても自動で追従するのも嬉しいです!
検証ツールでダーク表示を確認する
OS設定をいちいち切り替えるのは面倒です。Chrome DevTools(検証ツール)には便利な機能があります。prefers-color-scheme を仮想的に再現できます。
使い方はシンプルです。
- サイトを開いて、検証ツールを開く(macOSは
Cmd + Option + I) Cmd + Shift + Pでコマンドパレットを開く- 「Show Rendering」と入力して選択
- 下部に出てくる Rendering タブを開く
- 「Emulate CSS media feature prefers-color-scheme」を「dark」に切り替える

これで、OS設定はそのままにブラウザ表示だけダーク扱いに切り替わります。data-theme による手動切替を作る前の段階で、デザインの確認に重宝します。
僕もライト・ダークの両方で、文字の読みやすさやコントラストが保てているかを、ここで一通りチェックしました。
おわりに
AoiWebのダークモード対応を、Primitive・Semantic・light-dark() の3段階で進めた記録でした。
サイトの規模や既存CSSの書き方によっては、今回と同じ手順がそのまま当てはまるわけではないと思います。
次は data-theme を使った手動切替も試すつもりです。light-dark() だけでも、ぜひ一度試してみてください!