ダークモードに対応させようとすると、@media (prefers-color-scheme: dark) のブロックを用意して、背景色も文字色も枠線の色も、もう一度全部書き直すことになります。色の数が増えるほどこの二重管理は重くなり、「ライト用のこの色に対応するダーク用の色はどれか」がコードの離れた場所に散らばっていきます。CSS の light-dark() 関数は、この対をひとつのプロパティの中に並べて書けるようにするものです。この記事では light-dark() の基本の書き方、必ずセットで必要になる color-scheme の役割、カスタムプロパティとの組み合わせ、テーマ切り替えボタンの作り方、そして色が変わらないときの原因までを、動かせるデモ付きで解説します。
目次
これまでのダークモード対応で面倒だったこと
OS やブラウザーの配色設定を CSS から読み取る方法として、以前から @media (prefers-color-scheme: dark) というメディア特性が使われてきました。利用者がダークモードを選んでいるときだけ中のルールが有効になる、という仕組みです。書き方としては次のようになります。
/* ライト時の色 */
.card {
background-color: #ffffff;
color: #1b1f24;
border: 1px solid #dde1e6;
}
/* ダーク時の色をもう一度書き直す */
@media (prefers-color-scheme: dark) {
.card {
background-color: #16191d;
color: #e8eaed;
border-color: #343b43;
}
}
3行の色を切り替えるためにセレクターごと書き直しているのが分かります。これが数十か所になると、ライト側を直したのにダーク側を直し忘れる、といった食い違いが起きやすくなります。#ffffff の相方が #16191d だという情報が、ファイルの離れた場所にしか書かれていないことが原因です。
light-dark() を使うと、この対を1つのプロパティの中に収められます。先ほどのコードは次の3行と同じ意味になります。
:root {
color-scheme: light dark; /* これが必須(後述) */
}
.card {
background-color: light-dark(#ffffff, #16191d);
color: light-dark(#1b1f24, #e8eaed);
border: 1px solid light-dark(#dde1e6, #343b43);
}
light-dark() の基本の書き方
light-dark() は色を2つ受け取る関数で、第1引数がライト配色のときに使われる色、第2引数がダーク配色のときに使われる色です。引数はカンマで区切ります。
/* 明るいとき #222、暗いとき #eee */
color: light-dark(#222222, #eeeeee);
受け取れるのは色だけです。長さや画像は渡せないので、余白やフォントサイズをモードで変えたい場合は従来どおりメディアクエリーを使います。逆にいえば、<color> を書ける場所であればどこでも使えます。background-color や border-color はもちろん、box-shadow の色の部分や linear-gradient() の内側に書いても構いません。
color-scheme の指定が前提になる
ここが light-dark() でいちばん引っかかりやすいところです。color-scheme プロパティでダーク配色を許可していないと、ダークモードにしても常に第1引数(ライト側)が使われます。ページ全体で有効にするなら、ルート要素に次の1行を書きます。
:root {
color-scheme: light dark;
}
color-scheme は「この要素はどの配色に対応しているか」をブラウザーに申告するプロパティです。light dark は「どちらでも表示できる」という意味で、実際にどちらになるかは OS やブラウザーのユーザー設定に従います。申告する効果は light-dark() だけにとどまりません。チェックボックスやセレクトボックスなどのフォームコントロール、スクロールバー、テキスト入力欄のキャレットといったブラウザーが描く部品の配色も、申告した配色に合わせて描き分けられるようになります。ダークモードのページで入力欄だけ真っ白に光る、という現象はこの1行で解消できます。
どちらの色が使われるかは color-scheme が決める
light-dark() がどちらの引数を返すかは、メディアクエリーではなくその要素に適用されている color-scheme の計算値で決まります。値ごとの挙動は次のとおりです。
| color-scheme の値 | light-dark() の結果と効果 |
|---|---|
normal | 初期値。特定の配色を申告しない状態で、第1引数(ライト側)が使われる |
light | ライト配色だけに対応すると申告する。OS がダークモードでも第1引数が使われる |
dark | ダーク配色だけに対応すると申告する。OS の設定にかかわらず第2引数が使われる |
light dark | 両方に対応すると申告する。OS やブラウザーのユーザー設定に従ってどちらかが使われる |
only light | only キーワードを添えると、ブラウザーが独自に配色を変換することを禁止できる |
注目したいのは light と dark を要素に直接指定できる点です。color-scheme は継承するプロパティなので、ページの一部分にだけ color-scheme: dark を指定すれば、その要素と子孫の light-dark() だけがダーク側の色を返します。ライトなページの中にダークなヒーローエリアを置く、といった部分的な反転がメディアクエリーなしで書けます。
次のデモは、まったく同じ light-dark() の指定を当てた2枚のカードに、color-scheme: light と color-scheme: dark をそれぞれ指定したものです。CSS の色の指定は .card の1か所しか書いていないのに、表示が入れ替わることを確認してみてください。
<div class="cards">
<div class="card light">
<h3>color-scheme: light</h3>
<p>1つ目の色(ライト用)が使われます。</p>
</div>
<div class="card dark">
<h3>color-scheme: dark</h3>
<p>2つ目の色(ダーク用)が使われます。</p>
</div>
</div>
/* 同じ light-dark() の指定を、2枚のカードに共通で当てる */
.card {
background-color: light-dark(#ffffff, #1b1f24);
color: light-dark(#1b1f24, #e8eaed);
border: 1px solid light-dark(#d7dbe0, #3a4149);
border-radius: 8px;
padding: 16px 20px;
}
/* 左のカードはライト配色に固定 */
.light {
color-scheme: light;
}
/* 右のカードはダーク配色に固定 */
.dark {
color-scheme: dark;
}
/* 以下は並べて見せるためのレイアウト */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
font-family: system-ui, sans-serif;
}
.card h3 {
margin: 0 0 8px;
font-size: 16px;
}
.card p {
margin: 0;
font-size: 14px;
}
カスタムプロパティにまとめて色を設計する
実際のサイトでは、色を1つずつ light-dark() で書くよりも、カスタムプロパティ(CSS 変数)に対をまとめてしまう設計が扱いやすくなります。パレットの定義が一箇所に集まり、個々のセレクターでは var() を参照するだけで済むからです。
:root {
color-scheme: light dark;
/* 明・暗の対をここにまとめる */
--bg: light-dark(#ffffff, #16191d);
--text: light-dark(#1b1f24, #e8eaed);
--border: light-dark(#dde1e6, #343b43);
--shadow: light-dark(rgb(0 0 0 / 12%), rgb(0 0 0 / 60%));
}
.card {
background-color: var(--bg);
color: var(--text);
border: 1px solid var(--border);
box-shadow: 0 2px 12px var(--shadow);
}
色の定義とコンポーネントの見た目が分離されるので、配色を調整したいときに触る場所が :root の数行だけになります。既に変数でパレットを組んでいるサイトなら、変数の値を light-dark() に置き換えるだけで移行できることも多いはずです。
下のデモでは、背景色・文字色・枠線・影に加えて、ボタンの linear-gradient() の中の2色まで light-dark() で指定しています。色を書ける場所ならどこでも使えることが分かるはずです。
<div class="panels">
<section class="panel light">
<h3>Light</h3>
<p>背景・枠線・影・グラデーションを、すべて変数にまとめた light-dark() で指定しています。</p>
<button class="btn">送信する</button>
</section>
<section class="panel dark">
<h3>Dark</h3>
<p>背景・枠線・影・グラデーションを、すべて変数にまとめた light-dark() で指定しています。</p>
<button class="btn">送信する</button>
</section>
</div>
/* 色の対を変数にまとめる */
.panel {
--bg: light-dark(#ffffff, #16191d);
--text: light-dark(#1b1f24, #e8eaed);
--border: light-dark(#dde1e6, #343b43);
--shadow: light-dark(rgb(0 0 0 / 12%), rgb(0 0 0 / 60%));
--accent-from: light-dark(#3b82f6, #1e3a8a);
--accent-to: light-dark(#8b5cf6, #4c1d95);
background-color: var(--bg);
color: var(--text);
/* border-color にも box-shadow の色にも使える */
border: 1px solid var(--border);
box-shadow: 0 2px 12px var(--shadow);
border-radius: 10px;
padding: 20px;
}
.btn {
/* linear-gradient() の中の色にも使える */
background-image: linear-gradient(90deg, var(--accent-from), var(--accent-to));
color: #ffffff;
border: 0;
border-radius: 6px;
padding: 8px 18px;
font-size: 14px;
cursor: pointer;
}
.light { color-scheme: light; }
.dark { color-scheme: dark; }
/* 以下は並べて見せるためのレイアウト */
.panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 16px;
font-family: system-ui, sans-serif;
}
.panel h3 { margin: 0 0 8px; font-size: 16px; }
.panel p { margin: 0 0 14px; font-size: 14px; line-height: 1.7; }
サイト内にテーマ切り替えボタンを置く
OS の設定に従うだけでなく、サイト側で「ライト/ダーク」を切り替えたいこともあります。light-dark() の出力は color-scheme で決まるので、切り替え時に上書きするのは color-scheme 1つだけで済みます。色の指定そのものは一行も書き直す必要がありません。
/* 既定は OS の設定に従う */
:root {
color-scheme: light dark;
}
/* 利用者が明示的に選んだときだけ上書きする */
html[data-theme="light"] {
color-scheme: light;
}
html[data-theme="dark"] {
color-scheme: dark;
}
あとは JavaScript で <html> の data-theme 属性を書き換えるだけです。属性を外せば「OS の設定に従う」状態に戻せるので、ライト・ダーク・自動の3状態も無理なく作れます。実際のサイトでは選んだ値を localStorage に保存し、ページ読み込み時に読み戻します。
次のデモのボタンを押すと、data-theme の値が切り替わります。CSS 側で色を書き分けている箇所はなく、切り替わっているのは color-scheme だけです。
<button id="toggle" class="toggle" type="button">ダークモードに切り替える</button>
<div class="card">
<h3>テーマ切り替えの例</h3>
<p>ボタンで color-scheme を切り替えると、light-dark() で書いた色がまとめて入れ替わります。</p>
</div>
/* 既定は OS の設定に従う */
:root {
color-scheme: light dark;
}
/* ユーザーが明示的に選んだときだけ color-scheme を上書きする */
html[data-theme="light"] {
color-scheme: light;
}
html[data-theme="dark"] {
color-scheme: dark;
}
/* 色の指定は light-dark() のまま、1箇所も書き直さない */
body {
background-color: light-dark(#f4f6f8, #0f1216);
color: light-dark(#1b1f24, #e8eaed);
font-family: system-ui, sans-serif;
margin: 0;
padding: 20px;
}
.card {
background-color: light-dark(#ffffff, #1a1e24);
border: 1px solid light-dark(#dde1e6, #333b44);
border-radius: 10px;
padding: 16px 20px;
margin-top: 16px;
}
.card h3 { margin: 0 0 8px; font-size: 16px; }
.card p { margin: 0; font-size: 14px; line-height: 1.7; }
.toggle {
background-color: light-dark(#1b1f24, #e8eaed);
color: light-dark(#ffffff, #16191d);
border: 0;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
const root = document.documentElement;
const button = document.getElementById('toggle');
// 初期値はライト。実際のサイトでは localStorage などに保存した値を読み込む
root.dataset.theme = 'light';
button.addEventListener('click', () => {
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
button.textContent =
next === 'dark' ? 'ライトモードに切り替える' : 'ダークモードに切り替える';
});
対応ブラウザーとフォールバックの書き方
light-dark() は比較的新しい機能です。Firefox が 120 で対応し、Chrome と Edge は 123、Safari は 17.5 で対応しました。主要ブラウザーが出そろったのは2024年で、現在の最新版であればいずれも問題なく動きます。一方で、それ以前のバージョンが残っている環境では light-dark() を含む宣言ごと無視され、その色は指定されなかった扱いになります。背景色だけが抜け落ちて文字が読めなくなる、といった事故を避けたい場合はフォールバックを用意します。
安全なのは、従来のメディアクエリーを土台として書いたうえで、@supports で対応ブラウザーだけ light-dark() に差し替える形です。カスタムプロパティにまとめておけば、差し替える箇所も数行に収まります。
/* 1. 非対応ブラウザー向けの従来の書き方 */
:root {
color-scheme: light dark;
--bg: #ffffff;
--text: #1b1f24;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16191d;
--text: #e8eaed;
}
}
/* 2. 対応ブラウザーでは light-dark() に差し替える */
@supports (color: light-dark(#fff, #000)) {
:root {
--bg: light-dark(#ffffff, #16191d);
--text: light-dark(#1b1f24, #e8eaed);
}
}
@supports の条件は「そのプロパティにその値を書けるか」を試すものなので、color: light-dark(#fff, #000) のように実際に使える形で書きます。非対応ブラウザーはこのブロックごと読み飛ばし、上のメディアクエリーの結果がそのまま残ります。
なお data-theme によるトグルも併用する場合は、非対応ブラウザー向けに html[data-theme="dark"] で変数を上書きするルールを別途用意する必要があります。color-scheme の上書きだけで色が変わるのは light-dark() が効いているときだけだからです。
ダークモードにしても色が変わらないとき
書いたとおりに動かないときの原因は、ほとんどが color-scheme の指定漏れか、指定した場所のずれです。上から順に確認していくと切り分けられます。
color-scheme をどこにも書いていない
いちばん多い原因です。color-scheme の初期値は normal で、この状態ではダーク配色を申告していないことになるため、OS をダークモードにしても light-dark() は第1引数を返し続けます。関数自体は正しく動いていて、ライト側の色を選び続けているだけ、という状態です。:root { color-scheme: light dark; } を追加すれば解決します。
color-scheme を指定した要素より外側で light-dark() を使っている
light-dark() が見るのはその宣言が適用されている要素自身の color-scheme であって、ページ内のどこかに color-scheme があればよいわけではありません。たとえば .wrapper { color-scheme: dark; } と書いたうえで、その親である body に background-color: light-dark(...) を指定しても、body 自身の color-scheme は normal のままなのでライト側の色になります。color-scheme は継承するので、反転させたい範囲の一番外側の要素に指定すると考えると間違えにくくなります。
color-scheme を light だけにしている
color-scheme: light は「ライト配色にしか対応していない」という申告なので、OS の設定がダークでも第1引数が使われます。フォームコントロールの見た目をライトに固定したくてこの値を使っていると、light-dark() まで一緒に固定されてしまいます。OS の設定に追従させたいなら、値は必ず light dark のように2つ並べて書きます。
引数の順番が逆になっている
「色は変わるが明暗が逆」という場合は、単純に引数の順番を取り違えています。light-dark() は前がライト用、後ろがダーク用です。light-dark(#16191d, #ffffff) と書けば、ライトモードで暗い背景、ダークモードで明るい背景になります。関数名の light、dark の並び順と同じ、と覚えておくと迷いません。
まとめ
light-dark() は、ライト用とダーク用の色を1つのプロパティの中に並べて書ける関数です。第1引数がライト、第2引数がダークで、色を書ける場所ならどこでも使えます。これまでメディアクエリーのブロックを丸ごと複製していた部分が不要になり、対になる色が隣に並ぶぶん保守もしやすくなります。
使ううえで欠かせないのが color-scheme です。:root { color-scheme: light dark; } を書いて初めて OS の設定に追従するようになり、同時にフォームコントロールやスクロールバーの配色も整います。要素ごとに color-scheme: dark を指定すればその範囲だけ反転でき、html[data-theme="dark"] のようなセレクターで上書きすればテーマ切り替えボタンも作れます。
対応が広がったのは2024年なので、古い環境も見るサイトでは @media (prefers-color-scheme: dark) を土台に置き、@supports で差し替える形にしておくと安全です。まずはカスタムプロパティのパレットを light-dark() に置き換えるところから試してみてください。