「Webアプリのダークモード対応って地味に難しいよ」という話
はじめに
現代的でリッチなWebアプリは大抵、明るい配色(ライトモード)と暗い配色(ダークモード)の両方に対応しているものです。そして、ライトモードとダークモードをそのWebアプリの設定画面から切り替えられることも少なくありません。これはどのように実装されているのでしょうか? この記事では、よく紹介される実装方法を分析し、「究極的に理想的なカラースキーム選択」が実は結構難しいものであることについてまとめます。
(個人的な頭の整理のために書き始めた記事であり、もしかしたら誤りがあるかもしれません。とても複雑なので……。)
一昔前の実装方法
詳しくは後述しますが、現代では様々な技術領域でそれぞれの形でカラースキーム選択が実装できるようになっています。しかしまずは、驚くほど何のAPIもなかった一昔前にカラースキーム選択がどのように実装されていたのか振り返っておきましょう。
一昔前は、html
やbody
のようなHTMLドキュメントのルートに近い要素にdark
のようなクラスを付与することによって、CSS側で定義しておいた配色が使われるようにすることが一般的でした。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>一昔前のWebアプリにおけるカラースキーム選択の実装例</title>
<style>
html {
background-color: white;
color: black;
}
html.dark {
background-color: black;
color: white;
}
</style>
<script>
const colorScheme = localStorage.getItem("colorScheme");
if (colorScheme === "dark") {
document.documentElement.classList.add("dark");
}
</script>
</head>
<body>
<!-- 省略 -->
</body>
</html>
この例では、あらかじめWebアプリの設定画面などでlocalStorage.setItem("colorScheme", userChoise)
のようにしておくことで、Webアプリ読み込み時にその選択してあったカラースキームが適用されるようにしています。
ここで注目すべきは、クラスを付与するJavaScriptが、head
要素のbeforeend
に位置するscript
要素のコンテンツとして書かれていることです。
HTMLのパース中にscript
要素を見つけたとき、WebブラウザはHTMLのパースを一時中断し、そのscript
要素のコンテンツであるJavaScriptの実行を行います。(src
属性により外部からスクリプトを読み込むように指定されていた場合はその読み込みも行います。)そのため、例えばhead
要素の中に処理に時間のかかるJavaScriptを書くと、JavaScriptの処理中に画面がまっさらなままになってしまうという問題があります。それゆえ基本的には、script
要素はbody
要素のbeforeend
に置き、HTMLのパースが邪魔されないようにするものです。
しかしこの例ではあえてbody
要素すらまだ宣言されていない段階でスクリプトを実行しています。これは、カラースキームはできる限り早く反映しておくことがUX的に重要であるためです。ほんの少しでもカラースキーム反映処理を遅らせた場合、カラースキームの適用されていないコンテンツがごく短い間だけ表示されてしまうという、Flash of Unstyled Content現象の一種が発生してしまいます。ダークテーマが読み込まれるまでの一瞬だけ閃光のようにライトテーマが表示されることは避けなければなりません。
このFOUC現象の防止は現代でも重要な論点です。JavaScriptのニーズは一昔前より高まっていて、重量級のJavaScriptライブラリを読み込む場面も少なくありません。できる限り早くカラースキームを適用することが重要であり、そしてそのためにカラースキーム適用処理だけを切り出しておくことは有用であることを、現代でも気に留めておく必要があります。
(補足ですが、現代ではscript
要素にasync
やdefer
、type="module"
などの属性があるため、HTML内でのscript
要素の配置場所について気にする必要はまずありません。)
prefers-color-scheme
メディア特性
CSSの2020年頃から、CSSのメディア特性の一つ、prefers-color-scheme
が一般的に利用可能になりました。
このメディア特性は、ユーザーエージェント(Webブラウザ)が望むカラースキームを判定するときに使えるものです。Webブラウザは大抵デフォルトではOSが要求するカラースキームをデフォルト値として使うため、これは実質OSのカラースキームに合わせるためのものと言えるでしょう。
例えば上の例は次のようなJavaScriptのない形に書き換えることができます。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>一昔前のWebアプリにおけるカラースキーム選択の実装例</title>
<style>
html {
background-color: white;
color: black;
}
@media (prefers-color-scheme: dark) {
html {
background-color: black;
color: white;
}
}
</style>
</head>
<body>
<!-- 省略 -->
</body>
</html>
ただし注意すべきは、これはユーザーによる明示的な設定ではなく、もしかしたらユーザーがprefers-color-scheme
で示されたカラースキーム以外を求めているかもしれません。想像しにくい状況ではありますが、例えばOSは暗めの配色で、Webアプリは明るめの配色で使いたいというユーザーがいたとしたら、この実装では対応できません。
よって、JavaScript側からこのメディア特性を利用するというのも選択肢になってきます。Webアプリの設定としてカラースキームが明示的に設定されていた場合はそれを使い、設定されていなかった場合にprefers-color-scheme
の値を参照するのです。
const colorScheme = localStorage.getItem("colorScheme");
const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
const isDarkColorSchemePrefered = mediaQueryList.matches;
if (
(colorScheme === null && isDarkColorSchemePrefered) ||
(colorScheme === "dark")
) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
mediaQueryList.addEventListener("change", ({ matches }) => {
if (colorScheme !== null) return;
if (matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
});
color-scheme
プロパティとlight-dark()
関数
CSSの上記実装ではhtml
要素に対しbackground-color
とcolor
の値を設定することでダークモードに対応していますが、これはダークモード対応としては不十分です。background-color
やcolor
以外にも色を変えるべきプロパティは多くありますし、html
要素以外にもスタイルを上書きすべき要素は多くあります。
2022年頃から、CSSにはcolor-scheme
というプロパティがあり、これを使うことでフォームコントロールやスクロールバーなどを含むあらゆる要素に使用するデフォルトの色を選択することができます。
/* ライトモードで表示 */
input {
color-scheme: light;
}
/* ダークモードで表示 */
input {
color-scheme: dark;
}
/*
* ライトモードを優先しつつもダークモードも選択可能
* どちらで表示されるかは環境次第
*/
input {
color-scheme: light dark;
}
一般的にcolor-scheme
プロパティは:root
要素に対し設定されます。また、only light
やonly dark
といった指定方法もあり、これはユーザーエージェントの自動ダークモードなどの機能による上書きを認めないことを表明するものです。
また、prefers-color-scheme
メディア特性に応じて色を分ける処理は頻出パターンです。そのためか、CSSには2024年頃からlight-dark()
関数があり、次のように使うことができます。
.foo {
background-color: light-dark(white, black);
color: light-dark(black, white);
}
light-dark()
関数を使うには、基本的には、color-scheme
プロパティにlight dark
という値がなければならないとされています。
しかしここでも、ユーザーがOSのカラースキーム設定と反する配色を求めている場面には対応できない点には注意が必要です。そのような場面にも対応するには、CSSのcolor-scheme
プロパティや、後述のHTMLの<meta name="color-scheme">
要素をJavaScript側で制御する必要があります。
<meta name="color-scheme">
HTMLの現代のHTMLにはありがたいことに<meta name="color-scheme">
要素というものがあり、CSSのcolor-scheme
プロパティのようにフォームコントロールやスクロールバーなどを含むあらゆる要素に使用する色を、HTML側からも指定することができます。
下記のような実装では、ライトモードを基本としつつ、ダークモードも選択可能であることがユーザーエージェントに対し示されます。ユーザーエージェントはCSSのcolor-scheme
プロパティの場合と同じように、UAスタイルシートを選択するようになります。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="color-scheme" content="light dark">
<title>meta[name="color-scheme"]を使ったUAスタイルシート選択の実装例</title>
<style>/* 省略 */</style>
<script>/* 省略 */</script>
</head>
<body>
<!-- 省略 -->
</body>
</html>
このmeta
要素でも、CSSのcolor-scheme
プロパティ同様、CSSのlight-dark()
関数が利用可能になります。そしてこのmeta
要素をJavaScriptで後から書き換えたときにも、light-dark()
関数は再評価され、反映されます。
ここで、ユーザーがOSのカラースキーム設定と反する配色を求めている場面にも対応するには、meta
要素をJavaScript側で制御する必要があります。
const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
const isDarkColorSchemePrefered = mediaQueryList.matches;
+const meta = document.querySelector('meta[name="color-scheme"]');
+if (!(meta instanceof HTMLMetaElement)) throw new Error();
+
if (
(colorScheme === null && isDarkColorSchemePrefered) ||
(colorScheme === "dark")
) {
document.documentElement.classList.add("dark");
+ meta.setAttribute("content", "dark");
} else {
document.documentElement.classList.remove("dark");
+ meta.setAttribute("content", "light");
}
mediaQueryList.addEventListener("change", ({ matches }) => {
if (colorScheme !== null) return;
if (matches) {
document.documentElement.classList.add("dark");
+ meta.setAttribute("content", "dark");
} else {
document.documentElement.classList.remove("dark");
+ meta.setAttribute("content", "light");
}
});
Sec-CH-Prefers-Color-Scheme
ヘッダー
HTTPの2025年の記事執筆時点で実験的な機能として、HTTPにはSec-CH-Prefers-Color-Scheme
というリクエストヘッダーがあり、これを使うとサーバ側がカラースキームに応じたコンテンツを返すことができます。
MDNによるとパフォーマンスが求められる場面でカラースキーム関連の処理をインライン化する際に有用であるとのことです。逆に言えば、インライン化するほどではない場合は、従来のprefers-color-scheme
やwindow.matchMedia()
で対応可能であるということです。
現在、このリクエストヘッダーにはChromiumブラウザのみが対応しており、FirefoxとSafariが対応していません。
整理
現代では様々な技術領域でそれぞれの形でカラースキーム選択が実装できるようになっており、それぞれが影響を与えたり与えなかったりするため、正直複雑です。
- HTTP:
Sec-CH-Prefers-Color-Scheme
リクエストヘッダー - HTML:
<meta name="color-scheme">
要素 - CSS:
prefers-color-scheme
メディア特性やcolor-scheme
プロパティ、それを元にしたlight-dark()
関数 - JavaScript:
window.matchMedia()
によるCSSメディアクエリの呼び出し
これらのおおまかな関係としては、
- HTTPの
Sec-CH-Prefers-Color-Scheme
はインライン化のためのものであるため一般的には気にしなくてよい。 - HTMLの
<meta name="color-scheme">
とCSSのcolor-scheme
には機能的に重複がある。とはいえHTML側でlight
などと指定しても、color-scheme
は初期値のnormal
のままであり、JavaScriptからwindow.getComputedStyle()
して読み取ることなどはできない。 -
prefers-color-scheme
はlight-dark()
の利用でほぼ代替可能。JavaScript側で判定したい際にwindow.matchMedia()
で使うのが主か? とはいえこれも<meta name="color-scheme">
と一致しない状況は当然ある。 - そもそもユーザーエージェントが求めるカラースキームは任意のタイミングで切り替えられることがあるため、JavaScript側で切り替えを検知するにはイベントリスナーを使う必要がある。
以上より、実装方針としては以下のようになるかと考えます。
- ユーザーエージェントが求めるカラースキームでデフォルトの色を変えておきたい!
- CSSの
color-scheme
プロパティ、もしくはHTMLの<meta name="color-scheme">
要素を使いましょう。 - CSSのプロパティであれば要素ごとに指定できる柔軟性があります。
- CSSの
- ユーザーエージェントが求めるカラースキームを元にCSSで色を付けたい!
- CSSの
light-dark()
関数を使いましょう。
- CSSの
- ユーザーエージェントが求めるカラースキームをJavaScriptで扱いたい!
- JavaScriptから
window.matchMedia("(prefers-color-scheme: dark)").matches
しましょう。 - ただしカラースキームは任意のタイミングで切り替わるかもしれません。イベントリスナーを登録しておきましょう。
- JavaScriptから
- ユーザーがWebアプリの設定画面からライトテーマ/ダークテーマを選択できるようにしたい!
- クラス名やCSSカスタムプロパティ(変数)を使って実装する形になるでしょう。
light-dark()
関数などはさほど役に立ちません。 - CSSの
color-scheme
プロパティやHTMLの<meta name="color-scheme">
要素をJavaScriptから書き換えることをお忘れなく。
- クラス名やCSSカスタムプロパティ(変数)を使って実装する形になるでしょう。
おわりに
Webブラウザ側にWebページのカラースキームを選択できる統一的なAPIが実装されてくれると助かるのですが、流石にそこまでは求めすぎなのでしょうね……。
ちなみにカラースキームは現在はlight
かdark
の2択ですが、CSS Color Adjustment Moduleの仕様書では、将来のためにそれ以外の識別子も取れるようになっています。(現在はカスタム識別子の処理はされませんが、仕様には違反しません。)
<custom-ident> values are meaningless, and exist only for future compatibility, so that future added color schemes do not invalidate the color-scheme declaration in legacy user agents. User agents must not interpret any <custom-ident> values as having a meaning; any additional recognized color schemes must be explicitly added to this property’s grammar.
<custom-ident> の値は意味を持たず、将来の互換性のためにのみ存在します。これは、将来追加されるカラースキームによって、従来のユーザーエージェントにおける color-scheme 宣言が無効にならないようにするためです。ユーザーエージェントは、<custom-ident> の値を意味を持つものとして解釈してはなりません。認識される追加のカラースキームは、このプロパティの文法に明示的に追加する必要があります。
ですので将来のことを考えると、light
かdark
の2択であることを前提に設計してはいけません。
いろいろ難しいですね……。
Discussion