開発者は手っ取り早い成果を好みます。「ダークモードを追加して」というチケットが飛んできたとき、最も抵抗の少ない方法は明白に見えます。light.cssを書き、dark.cssを書き、その間を切り替える。一見、クリーンで迅速な開発に思えます。コンポーネントが3つしかない小さなサイドプロジェクトなら、それで通用するかもしれません。しかし、アプリケーションがいくつかのモジュールを超えて成長すると、その2つ目のファイルは資産ではなく、二重にメンテナンスしなければならない負債へと変わります。

2ファイル・トラップ

一見すると、そのロジックは理にかなっているように見えます。「関心の分離」ですよね?ライトなものはここに、ダークなものはあちらに。エディタで2つのバッファを開き、ライト用のファイルからカードのスタイルをダーク用のファイルにコピーし、#ffffff#1a1a1aに置き換えて、作業終了。

問題は最初の1週間ではありません。問題は6ヶ月後、デザイナーがプライマリボタンのボーダー半径をわずかに変更するよう求めてきたり、プロダクトチームがチェックアウトフォームに新しい警告状態を追加したいと言い出したときに起こります。ライト用のスタイルシートを更新します。ダーク用のスタイルシートをざっと確認します。変更をコピーし忘れないようにするかもしれませんが、忘れてしまうこともあるでしょう。その「差」こそが、品質が損なわれる場所です。あなたはもはや1つのインターフェースをメンテナンスしているのではなく、同じHTML構造を共有しているだけの、2つの並行したインターフェースをメンテナンスしていることになるのです。

テーマ・ドリフトは避けられない

この「差」には、フロントエンドチームが認識し始めている名前があります。「テーマ・ドリフト(theme drift)」です。これは、2つのスタイルシートが異なるスピードで進化するときに発生します。あちらでパディングを調整し、こちらでシャドウを微調整する。ダーク用のファイルは放置された兄弟のようになります。あるいはもっと悪いことに、恐怖の対象になります。開発者は、一方のテーマを変更すると、もう一方のファイルを探し回って同じ作業を繰り返さなければならないため、変更を避けるようになります。

認知負荷は急速に増大します。CSSを一度だけ書きたかったはずなのに、結局2回書くことになり、デザインシステムが変更されるたびにその「負債」に対する利息を支払うことになります。ライト用のファイルの flex gap を更新してダーク用のファイルへの反映を忘れたせいで、ダークモードでアイコンの配置がずれる。新しいアクセシビリティのルールが片方のシートにしか適用されず、フォーカスリングが消えてしまう。UIは単に見た目が悪いだけでなく、壊れているように感じ始めます。

セマンティック・トークンを活用する

解決策は、より優れたdiffツールや厳格なコードレビューではありません。解決策は、色に対する考え方を変えることです。見た目そのものでスタイルを整理するのをやめ、目的で整理し始めましょう。ここで「セマンティック・トークン」が登場します。

カードに「白の背景」を割り当てるのではなく、「サーフェス(表面)の背景」を割り当てます。テキストに「黒かオフホワイトか」を選ぶのではなく、「テキストカラー」を選びます。コンポーネントは、ユーザーがライトモードとダークモードのどちらを好むかを知る必要も、気にする必要もありません。ただ、自分の役割に一致するトークンを要求するだけです。

標準的なボタンを考えてみましょう。2ファイル方式の世界では、.btnはライト用のスタイルシートに存在し、白い背景と暗いボーダーを持っています。その双子はダーク用のスタイルシートに存在し、ほぼ黒い背景と明るいボーダーを持っています。これでは、1つのボタンに対してコードが2倍になります。トークンを使えば、.btnには1つの宣言しかありません。背景は var(--color-surface-secondary)、ボーダーは var(--color-border-default) です。値そのものはルート(root)に存在します。サイトがライトモードのとき、--color-surface-secondary#f8f9fa のような値に解決されます。ダークモードでは、同じトークンが #2d2d2d に解決されます。ボタンコンポーネント自体は決して変わりません。その下にあるデータだけが変わるのです。

この「構造」と「データ」の区別は、微妙ですが強力です。カードコンポーネントは、レイアウト、スペーシング、タイポグラフィ、および elevation(高さ)を一度だけ定義します。テーマレイヤーがパレットを定義します。この分離こそが、まさに CSS custom properties が作られた目的です。

アーキテクチャはどう変わるか

このアプローチは、スタイルの書き方を根本的に再構築します。

従来の方法は、通常以下のようになります:

  • パディング、半径、背景、テキストカラー、シャドウを定義するライト用のカード・スタイルシート。
  • 色を反転させるためだけに、ほぼ同じプロパティを再定義するダーク用のカード・スタイルシート。
  • どのスタイルシートをロードするか、あるいはbodyにどのクラスを切り替えるかを決定するロジックレイヤー。

新しい方法は、以下のようになります:

  • レイアウトを定義し、セマンティック・トークンを割り当てる1つのカード・スタイルシート。
  • ライトな文脈において、それらのトークンが何を意味するかを定義する1つのテーマファイル。
  • ダークな文脈において、それらのトークンが何を意味するかを定義する1つのテーマファイル(あるいは単に同じファイル内のブロック)。
  • コンポーネントレイヤーに触れることなく、値のレイヤーを変更する単一のアトリビュート(属性)の切り替え。

セットアップは安定したままです。変更するのはデータだけです。デザイナーがハイコントラストモードやミッドナイトブルーのバリエーションといった第3のテーマを導入したいとき、カードを書き直す必要はありません。トークンマップに割り当てを一つ追加するだけで済みます。コンポーネントはシンプルで依存のない状態を保ちます。コンポーネントは単にサーフェスカラーを必要としているだけで、どのサーフェスカラーを使うかはテーマが決定します。

データ属性による切り替え

実装はシンプルで読みやすいままにできます。HTMLタグに data-theme="dark" のようなデータ属性を適用し、その配下にトークンの定義をスコープさせます。

ライトモードの体験のために :root にデフォルト値を設定しておけば、JavaScriptが実行される前でもページが正しくレンダリングされます。その上で、[data-theme="dark"] の配下でトークンの値を上書きします。小さなスクリプトがトグルのクリックを監視して属性を更新すれば、ページ上のすべてのコンポーネントが即座に反応します。個々の要素に対してクラスを頻繁に書き換える必要も、レンダリングの途中で全く別のスタイルシートをインポートする必要もありません。ブラウザはすでに変数をメモリに保持しているため、新しい値で再描画するだけです。

これにより、非常に実用的な意味でコードをクリーンに保つことができます。.card のすべての箇所を見つけるために、2つのディレクトリをまたいで grep する必要はありません。同じノードに重なった競合するテーマクラスの間で、詳細度の争いに悩まされることもありません。HTMLは読みやすく、CSSは集約され、検索しやすい状態を維持できます。

バージョンではなく、値の問題である

ダークモードとは「値」の問題です。UIの第2のバージョンではありません。夜になったからといって、カードの角がより丸くなるわけでも、グリッドが異なる形状に崩れるわけでも、タイプスケールのリズムが変わる必要もありません。変化するのは色だけであり、時にはシャドウが少し深くなる程度です。ダークモードを「全面的なリスキン(見た目の作り直し)」として扱うのは、メンテナンスの悪夢を生むオーバーエンジニアリングです。

これを正しく理解しているチームは、デザインシステムをデータベースのように扱います。コンポーネントは名前によってプロパティをクエリ(問い合わせ)し、テーマはレコードを提供します。ライトからダークへの切り替えはクエリパラメータの変更であり、スキーマの書き換えではありません。

その考え方こそが、テーマの乖離(テーマドリフト)を防いでくれます。1つのカード、1つのボタン、そしてスペーシングとサイズに関する「信頼できる唯一の情報源(Source of Truth)」が1つある状態。パレットは論理的にマッピングされた1箇所に存在し、ユーザーが好むどのような環境にも対応できる準備ができています。

真の教訓

もしライトモードとダークモードのために2つのCSSファイルを維持しているなら、それはテーマ設定ではなく、単なる複製です。セマンティック・トークンに移行し、ルートレベルのデータ属性でそれらをスコープさせ、コンポーネントには外観をハードコードさせるのではなく「役割」を要求させるようにしましょう。最初のリファクタリングには労力がかかりますが、そうしない場合の代償は、並行するスタイルシートの間で延々と続く「モグラ叩き」のような作業です。同じカードを2回書くには、人生は短すぎます。