🌚

「Webアプリのダークモード対応って地味に難しいよ」という話

に公開

はじめに

現代的でリッチなWebアプリは大抵、明るい配色(ライトモード)と暗い配色(ダークモード)の両方に対応しているものです。そして、ライトモードとダークモードをそのWebアプリの設定画面から切り替えられることも少なくありません。これはどのように実装されているのでしょうか? この記事では、よく紹介される実装方法を分析し、「究極的に理想的なカラースキーム選択」が実は結構難しいものであることについてまとめます。

(個人的な頭の整理のために書き始めた記事であり、もしかしたら誤りがあるかもしれません。とても複雑なので……。)

一昔前の実装方法

詳しくは後述しますが、現代では様々な技術領域でそれぞれの形でカラースキーム選択が実装できるようになっています。しかしまずは、驚くほど何のAPIもなかった一昔前にカラースキーム選択がどのように実装されていたのか振り返っておきましょう。

一昔前は、htmlbodyのような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要素にasyncdefertype="module"などの属性があるため、HTML内でのscript要素の配置場所について気にする必要はまずありません。)

CSSのprefers-color-schemeメディア特性

2020年頃から、CSSのメディア特性の一つ、prefers-color-schemeが一般的に利用可能になりました。

https://developer.mozilla.org/ja/docs/Web/CSS/@media/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");
  }
});

CSSのcolor-schemeプロパティとlight-dark()関数

上記実装ではhtml要素に対しbackground-colorcolorの値を設定することでダークモードに対応していますが、これはダークモード対応としては不十分です。background-colorcolor以外にも色を変えるべきプロパティは多くありますし、html要素以外にもスタイルを上書きすべき要素は多くあります。

2022年頃から、CSSにはcolor-schemeというプロパティがあり、これを使うことでフォームコントロールやスクロールバーなどを含むあらゆる要素に使用するデフォルトの色を選択することができます。

https://developer.mozilla.org/ja/docs/Web/CSS/color-scheme

/* ライトモードで表示 */
input {
  color-scheme: light;
}

/* ダークモードで表示 */
input {
  color-scheme: dark;
}

/*
 * ライトモードを優先しつつもダークモードも選択可能
 * どちらで表示されるかは環境次第
 */
input {
  color-scheme: light dark;
}

一般的にcolor-schemeプロパティは:root要素に対し設定されます。また、only lightonly darkといった指定方法もあり、これはユーザーエージェントの自動ダークモードなどの機能による上書きを認めないことを表明するものです。

また、prefers-color-schemeメディア特性に応じて色を分ける処理は頻出パターンです。そのためか、CSSには2024年頃からlight-dark()関数があり、次のように使うことができます。

.foo {
  background-color: light-dark(white, black);
  color: light-dark(black, white);
}

https://developer.mozilla.org/ja/docs/Web/CSS/color_value/light-dark

light-dark()関数を使うには、基本的には、color-schemeプロパティにlight darkという値がなければならないとされています。

しかしここでも、ユーザーがOSのカラースキーム設定と反する配色を求めている場面には対応できない点には注意が必要です。そのような場面にも対応するには、CSSのcolor-schemeプロパティや、後述のHTMLの<meta name="color-scheme">要素をJavaScript側で制御する必要があります。

HTMLの<meta name="color-scheme">

現代のHTMLにはありがたいことに<meta name="color-scheme">要素というものがあり、CSSのcolor-schemeプロパティのようにフォームコントロールやスクロールバーなどを含むあらゆる要素に使用する色を、HTML側からも指定することができます。

https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name#color-scheme

下記のような実装では、ライトモードを基本としつつ、ダークモードも選択可能であることがユーザーエージェントに対し示されます。ユーザーエージェントは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");
   }
 });

HTTPのSec-CH-Prefers-Color-Schemeヘッダー

2025年の記事執筆時点で実験的な機能として、HTTPにはSec-CH-Prefers-Color-Schemeというリクエストヘッダーがあり、これを使うとサーバ側がカラースキームに応じたコンテンツを返すことができます。

https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-CH-Prefers-Color-Scheme

MDNによるとパフォーマンスが求められる場面でカラースキーム関連の処理をインライン化する際に有用であるとのことです。逆に言えば、インライン化するほどではない場合は、従来のprefers-color-schemewindow.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-schemelight-dark()の利用でほぼ代替可能。JavaScript側で判定したい際にwindow.matchMedia()で使うのが主か? とはいえこれも<meta name="color-scheme">と一致しない状況は当然ある。
  • そもそもユーザーエージェントが求めるカラースキームは任意のタイミングで切り替えられることがあるため、JavaScript側で切り替えを検知するにはイベントリスナーを使う必要がある。

以上より、実装方針としては以下のようになるかと考えます。

  • ユーザーエージェントが求めるカラースキームでデフォルトの色を変えておきたい!
    • CSSのcolor-schemeプロパティ、もしくはHTMLの<meta name="color-scheme">要素を使いましょう。
    • CSSのプロパティであれば要素ごとに指定できる柔軟性があります。
  • ユーザーエージェントが求めるカラースキームを元にCSSで色を付けたい!
    • CSSのlight-dark()関数を使いましょう。
  • ユーザーエージェントが求めるカラースキームをJavaScriptで扱いたい!
    • JavaScriptからwindow.matchMedia("(prefers-color-scheme: dark)").matchesしましょう。
    • ただしカラースキームは任意のタイミングで切り替わるかもしれません。イベントリスナーを登録しておきましょう。
  • ユーザーがWebアプリの設定画面からライトテーマ/ダークテーマを選択できるようにしたい!
    • クラス名やCSSカスタムプロパティ(変数)を使って実装する形になるでしょう。light-dark()関数などはさほど役に立ちません。
    • CSSのcolor-schemeプロパティやHTMLの<meta name="color-scheme">要素をJavaScriptから書き換えることをお忘れなく。

おわりに

Webブラウザ側にWebページのカラースキームを選択できる統一的なAPIが実装されてくれると助かるのですが、流石にそこまでは求めすぎなのでしょうね……。

ちなみにカラースキームは現在はlightdarkの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> の値を意味を持つものとして解釈してはなりません。認識される追加のカラースキームは、このプロパティの文法に明示的に追加する必要があります。

ですので将来のことを考えると、lightdarkの2択であることを前提に設計してはいけません。

いろいろ難しいですね……。

Discussion