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カスタムプロパティを参照してください。これでテーマ切り替えがだいぶスッキリしました。以上です。