本サイトに light-dark() でダークモード対応してみた
AoiWeb(このサイト)をダークモードに対応させました。
使ったのは light-dark() というCSSの関数です。ライトとダークの色を1行で書き分けられて、メディアクエリよりも見通しがいい。とはいえ、いきなりこの関数だけで完結するわけではなく、土台の整備に手間をかけました。今回は、その実装プロセスを順を追って紹介します。
同じように「サイトをダーク対応させたい」と考えている方の、設計の参考になれば嬉しいです。
完成形:OSの外観設定に追従する
まずは結果から。AoiWebのトップページは、お使いのOSの外観設定(ライト・ダーク)に合わせて、自動で色が切り替わるようになりました。


背景の青がほぼ反転し、モックアップ部分のカードもダーク基調に変わっています。文字色やリンクの強調色も、それぞれの背景に合わせて読みやすい色に調整されています。
設計の方針:いきなり light-dark() から始めない
今回の対応で一番重視したのは「段階的にやる」ということです。既存のサイトに後からダークモードを追加するとき、いきなり light-dark() を書き始めると、ほぼ確実に色が崩れます。なぜなら、既存のCSSに散らばった色指定が 「具体的な色」のまま固定 されているからです。
そこで今回は、3つのフェーズに分けて進めました。
- 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、--color-text-body、--color-bg-soft といった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() でダークモード対応する
いよいよダークモード対応です。やることは3つあります。
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)
);
}
oklch(from var(--color-main) calc(l - 0.13) c h) は「--color-main の明度を 0.13 下げた色」という意味です。彩度(c)と色相(h)はそのまま使い回します。
これを 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 による手動切替を作る前の段階で、デザインの確認に重宝します。
ライト・ダークの両方で、文字の読みやすさやコントラストが保てているかを、ここで一通りチェックしました。
まとめ:見えない品質へのこだわり
ダークモード対応は、単に「色を反転させる」作業ではなく、サイトの色設計そのものを見直す機会になりました。
ダークモードを使用している訪問者が「目が疲れない」と感じさせるためにも、やってよかったです。