CSS設計

CSSカスタムプロパティでテーマ切り替えを実装する

読了 6分
CSS設計

CSSカスタムプロパティでテーマ切り替えを実装する

ダークモード対応を、最初はJavaScriptで各要素の色を一個ずつ書き換える実装でやろうとして、かなり面倒なことになったので、そのときのメモです。

結論を先に書くと、CSSカスタムプロパティ(CSS変数)を使うと、色の「定義」と「利用」を分離できて、切り替えは値の差し替えだけで済みます。だいぶ楽になりました。

カスタムプロパティは「継承される変数」

カスタムプロパティは --名前 で定義して、var() で参照します。Sassなどのプリプロセッサ変数と違って、これは 実行時に効くCSSの値 で、しかも子孫要素に継承されます。

以下のようになります。

:root {
  --color-bg: #ffffff;
  --color-text: #1c1917;
}

body {
  background: var(--color-bg);
  color: var(--color-text);
}

:root(= html 要素)で定義すると、ページ全体がその値を受け継ぎます。ここが大事なところで、親で変数の値を変えれば、その配下すべての var() 参照が一斉に変わる のです。たぶんここがこの仕組みの肝だと思います。

テーマの差分だけを上書きする

この継承の性質を使うと、テーマ切り替えは「ルート要素の変数だけを差し替える」だけになります。ダークテーマ用の値を、属性セレクタでまとめて上書きします。

:root {
  --color-bg: #ffffff;
  --color-text: #1c1917;
  --color-accent: #0284c7;
}

:root[data-theme='dark'] {
  --color-bg: #1c1917;
  --color-text: #f5f5f4;
  --color-accent: #38bdf8;
}

ボタンやカードなど個々のコンポーネント側は var(--color-bg) を参照するだけで、テーマを意識する必要がありません。配色の知識が一か所に集まって、新しい部品を足してもテーマ対応が自動でついてきます。これは便利です。

切り替えはルート要素の属性だけ

JavaScriptの役割は「ルート要素の属性を切り替える」ことだけに減ります。色を直接いじらないので、対象要素を探し回らなくて済みます。

function toggleTheme() {
  const root = document.documentElement
  const next = root.dataset.theme === 'dark' ? 'light' : 'dark'
  root.dataset.theme = next
  localStorage.setItem('theme', next)
}

// 初期化:保存値か、OSの設定を尊重する
const saved = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.dataset.theme = saved ?? (prefersDark ? 'dark' : 'light')

prefers-color-scheme を初期値に使うと、ユーザーのOS設定を最初から尊重できます。保存値があればそちらを優先する、という二段構えが扱いやすいです。

ここでちらつきに注意

テーマ初期化を body の末尾やReactのマウント後に行うと、一瞬ライトテーマが見えてからダークに切り替わる「ちらつき(FOUC)」が起きます。これに最初ハマりました。

避けるには、ページ描画前 にテーマを確定させます。<head> 内の同期スクリプトで属性を付けてしまうのが定番のようです。

<script>
  const saved = localStorage.getItem('theme')
  const prefersDark = matchMedia('(prefers-color-scheme: dark)').matches
  document.documentElement.dataset.theme = saved ?? (prefersDark ? 'dark' : 'light')
</script>

プリプロセッサ変数との違い

Sassの変数とは別物なので、違いを整理しておきます。

Sass等の変数CSSカスタムプロパティ
評価のタイミングビルド時に確定実行時に評価される
実行時の変更できないvar() 参照が即追従
継承しない子孫に継承される
JSからの読み書きできないgetComputedStyle などで可能

テーマのように「実行時に値が変わる」用途では、カスタムプロパティでないと実現できません。最初Sass変数でやろうとして詰まったのは、たぶんここを分かっていなかったからです。

まとめ

  • カスタムプロパティはルート要素で定義すると全体に継承される
  • テーマごとに変数の値だけを上書きして、コンポーネントは var() を参照するだけにする
  • 切り替えはルート要素の属性変更で完結し、ちらつきは <head> の同期スクリプトで防ぐ

色だけでなく余白やフォントサイズなど、テーマで変えたい値はすべて同じ方法でまとめられます。詳しい仕様はMDNのCSSカスタムプロパティを参照してください。これでテーマ切り替えがだいぶスッキリしました。以上です。