CSS変数の使い方:var()・:root・テーマ切替をJSから動かす実装
CSS変数(カスタムプロパティ)の基本。--var と var()、:root とスコープ、継承、JSからの読み書き、ダークモード切替、フォールバックを、僕がハマった失敗込みで解説。
ボタンの色を変えてほしい、とだけ頼まれた日のことです。
僕は #2563eb という青を、CSSの中から探して回りました。ボタンに1か所、ホバーにもう1か所、リンクに1か所、グラフの凡例に1か所——気づけば14か所。13個直して、最後の1個を見落としました。リリース後、グラフの凡例だけが古い青のまま残っていたんです。
色を直接書く、というのはこういうことです。値があちこちに散らばって、変更が「宝探し」になる。CSS変数は、この宝探しを終わらせる仕組みです。色や余白に名前を付けて1か所で管理し、var() でそこを参照する。直すのは定義の1行だけになります。
この記事は、デザイントークン全体の設計論ではなく、CSS変数そのものの使い方に集中します。--var の書き方、var() とフォールバック、:root とスコープ、継承の挙動、JavaScriptからの読み書き、そしてテーマ切替まで。トークンを階層で整理する話はデザイントークン設計に分けてあります。
この記事の要点
- CSS変数(正式名はカスタムプロパティ)は
--名前: 値;で定義し、var(--名前)で読み出す。値の保管場所に名前を付ける機能。 :rootに置けばページ全体で使え、特定のセレクタに置けばそのスコープ内だけで効く。普通のCSSと同じく継承する。var(--名前, 代替値)の第2引数はフォールバック。定義漏れに備えた保険で、公開ページでは入れておくと事故が減る。- JavaScriptからは
getComputedStyle(...).getPropertyValue()で読み、element.style.setProperty()で書く。テーマ切替やカラーピッカーはこれだけで作れる。 - ダークモードは
data-theme属性とprefers-color-schemeを組み合わせ、変数の値を差し替えるだけで実現できる。
CSS変数の基本:--名前 と var()
まず最小の形です。変数を定義して、それを使う。これだけです。
:root {
--color-accent: #2563eb;
}
.button {
background: var(--color-accent);
}
ルールは2つだけ覚えれば足ります。定義はハイフン2つで始める(--color-accent)。読み出しは var() で囲む(var(--color-accent))。変数名は大文字小文字を区別するので、--main-color と --Main-color は別物です。僕は小文字とハイフンに統一しています。途中で揺れると、自分でも「あれ、どっちで書いたっけ」となるからです。
ここで1つ注意。CSS変数は値にしか使えません。プロパティ名やセレクタには使えないんです。var(--bg-prop): red; のような書き方や、メディアクエリの条件に変数を入れる書き方は動きません。「color: の右側に入るもの」だと思っておくと外しません。この勘違いは後の落とし穴でもう一度触れます。
:root とスコープ:どこで定義するかで効く範囲が変わる
:root は、HTMLの一番外側(<html> 要素)を指すセレクタです。ここに変数を置くと、ページのどこからでも参照できます。グローバル変数のような感覚ですね。
でも、変数は :root 専用ではありません。普通のセレクタに置けば、その要素とその中だけで効くようになります。スコープを絞れる、ということです。
:root {
--gap: 16px; /* ページ全体のデフォルト */
}
.card {
--gap: 24px; /* .card の中だけ上書き */
padding: var(--gap);
}
.card .tag {
margin: var(--gap); /* ここは 24px(.card の値を継承) */
}
.footer {
margin: var(--gap); /* ここは 16px(:root の値) */
}
.card の中では --gap が 24px に化け、その内側の .tag もそれを受け継ぎます。一方、.card の外にある .footer は :root の 16px のまま。これが効いてくるのは、たとえば「カードの中だけ余白を広げたい」「ダークなセクションの中だけ文字色を変えたい」といった場面です。同じ変数名のまま、場所ごとに値を切り替えられます。
継承される、という大事な性質
CSS変数は、普通のCSSプロパティと同じく子要素に継承されます。さっきの .tag が .card の値を受け継いだのは、この継承のおかげです。
これは便利な反面、ハマりどころでもあります。ある要素で変数を上書きすると、その子孫すべてに波及する。意図せず広い範囲が変わってしまうことがあるんです。逆に言えば、「ここから下だけ別テーマ」を作るのは得意で、親要素に変数をひとまとめに上書きするだけで、そのブロック全体の見た目がガラッと変わります。ダークモードの切替も、原理はこれと同じです。
| 置き場所 | 効く範囲 | 主な用途 |
|---|---|---|
:root | ページ全体 | 全体のデフォルト値 |
| 普通のセレクタ | その要素+子孫(継承) | 部分的な上書き、ブロック単位のテーマ |
[data-theme="dark"] | その属性が付いた要素以下 | ダーク/ライトの切替 |
var() のフォールバック:定義漏れの保険
var() には第2引数を渡せます。これがフォールバック(代替値)です。
.button {
background: var(--color-accent, #2563eb);
color: var(--button-text, #ffffff);
}
var(--color-accent, #2563eb) は、「--color-accent が定義されていれば使う。未定義なら #2563eb を使う」という意味です。MDNのvar() の解説でも、このフォールバックは実務でよく使う構文として扱われています。
なぜ保険が要るのか。フォールバックなしの var(--text) で定義が漏れていると、その宣言はまるごと無効になります。色を指定したつもりが何も効かず、ブラウザの初期色(だいたい黒)で表示される。背景も黒寄りのテーマだと、黒背景に黒文字、つまり何も読めない画面になります。僕は一度これでヘッダーのテキストを全部消しました。本人はCSSを書いたつもりなのに、画面には何も出ていない。原因が分かるまで30分溶かしました。
公開ページの根っこに近い色(背景・文字色)には、フォールバックを入れておく。これだけで「真っ黒画面」事故は避けられます。
JavaScriptからCSS変数を読み書きする
ここがCSS変数の強いところです。JavaScriptから値を読んだり、書き換えたりできます。テーマ切替もカラーピッカーも、この2つのAPIだけで作れます。
読むときは getComputedStyle() から getPropertyValue() を呼びます。
// :root に定義した変数を読む
const styles = getComputedStyle(document.documentElement);
const accent = styles.getPropertyValue("--color-accent").trim();
console.log(accent); // "#2563eb"
getPropertyValue() は前後に空白が付くことがあるので、.trim() を付ける癖をつけておくと比較でハマりません。
書くときは setProperty() です。要素の style に直接書き込む形になります。
// :root の変数を上書きする(ページ全体に効く)
document.documentElement.style.setProperty("--color-accent", "#16a34a");
// 特定の要素だけ上書きする(その要素と子孫に効く)
const card = document.querySelector(".card");
card.style.setProperty("--gap", "32px");
document.documentElement が <html> 要素、つまり :root です。ここに setProperty() すれば、CSS側で var(--color-accent) を使っている全部の場所が一斉に変わります。色を1か所変えるだけでサイト全体の印象が切り替わる感覚は、一度やると戻れません。
コピペで動く:テーマ切替+カラーピッカー
ここまでを1つにまとめます。下のHTML・CSS・JSをそのまま1つのHTMLファイルに貼れば、ライト/ダークの切替と、アクセント色の即時変更が動きます。フレームワークは不要です。
<section class="card">
<h2>CSS変数デモ</h2>
<p>背景・文字・枠・ボタンの色を、すべてCSS変数で管理しています。</p>
<button class="button" type="button" data-theme-toggle>テーマ切替</button>
<label>
アクセント色
<input type="color" value="#2563eb" data-accent-picker>
</label>
</section>
:root {
color-scheme: light;
--surface: #f8fafc;
--surface-raised: #ffffff;
--text: #0f172a;
--border: #cbd5e1;
--color-accent: #2563eb;
--radius: 12px;
--gap: 24px;
}
/* 手動でダークにしたとき */
[data-theme="dark"] {
color-scheme: dark;
--surface: #0f172a;
--surface-raised: #111827;
--text: #f8fafc;
--border: #334155;
}
/* OS設定がダークで、手動指定がないとき */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--surface: #0f172a;
--surface-raised: #111827;
--text: #f8fafc;
--border: #334155;
}
}
body {
margin: 0;
font-family: system-ui, "Noto Sans JP", sans-serif;
background: var(--surface, #ffffff);
color: var(--text, #111827);
}
.card {
max-width: 32rem;
margin: var(--gap) auto;
padding: var(--gap);
background: var(--surface-raised, #ffffff);
border: 1px solid var(--border, #d1d5db);
border-radius: var(--radius, 12px);
}
.button {
min-height: 2.75rem;
padding: 0 var(--gap);
border: none;
border-radius: calc(var(--radius) / 2);
background: var(--color-accent, #2563eb);
color: #ffffff;
font-weight: 700;
cursor: pointer;
}
const root = document.documentElement;
const KEY = "demo-theme";
// 保存済みテーマ、なければOS設定を初期値にする
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
const initial = localStorage.getItem(KEY) || (prefersDark ? "dark" : "light");
root.setAttribute("data-theme", initial);
// ライト⇔ダークを切り替えて保存
document.querySelector("[data-theme-toggle]").addEventListener("click", () => {
const next = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
root.setAttribute("data-theme", next);
localStorage.setItem(KEY, next);
});
// カラーピッカーでアクセント色を即時反映(不正値は弾く)
document.querySelector("[data-accent-picker]").addEventListener("input", (e) => {
const value = e.target.value;
if (CSS.supports("color", value)) {
root.style.setProperty("--color-accent", value);
}
});
仕組みを一言でいうと、JSは色を1つも持っていません。やっているのは data-theme 属性を付け替えることと、--color-accent を1行書き換えることだけ。見た目の責任はCSS変数側にあるので、JSがすっきりします。prefers-color-scheme はOSやブラウザのテーマ設定を読むメディア特性で、詳しくはMDNのprefers-color-schemeが正確です。手動切替を優先したいので、data-theme が付いていればそちらを勝たせています。
こんな場面で効く
ユースケースを具体的に3つ。どれも僕が実際に使ったものです。
- CTAやキャンペーンの色替え。LPのボタン・リンク・警告枠で同じ
--color-accentを参照しておくと、季節キャンペーンの色変更が定義の1行で終わります。冒頭の「14か所探し」が二度と起きません。 - 管理画面のテーマ統一。画面を別々に作ると、灰色の濃さや余白がじわじわズレます。
--surface・--border・--gapを共有しておけば、新しい画面も既存に寄ります。配色の切替を本格的にやるならダークモード実装の手順も合わせてどうぞ。 - 顧客別ブランディング。顧客Aは青、顧客Bは緑、というとき、
data-brand="customer-a"を親に付けて変数だけ差し替えます。コンポーネントを複製しないので、保守が重くなりません。スコープと継承がそのまま武器になる例です。
僕がハマった落とし穴4つ
正直に書きます。CSS変数で踏んだ地雷は、だいたいこの4つでした。
ひとつ目は、フォールバックなしの var()。さっきの「真っ黒画面」事故です。MDNのカスタムプロパティ仕様ページにもある通り、未定義だと宣言ごと無効になる。根っこの色には保険を入れる、で解決しました。
ふたつ目は、変数をプロパティ名やメディアクエリに使おうとしたこと。@media (min-width: var(--bp)) は動きません。変数は「値」専用です。レスポンシブの閾値を変数で持ちたい気持ちは分かりますが、そこは諦めて素直に書いています。
みっつ目は、継承の波及を読み違えたこと。あるラッパー要素で --text を上書きしたら、その中のボタンの文字色まで巻き込まれて消えました。上書きは「ここから下、全部」に効く。狭いスコープに置くか、上書きする範囲を意識する、で防げます。
よっつ目は、JSで入力値をそのまま setProperty() したこと。カラーピッカーは大丈夫でも、ユーザー入力を無検証で流すと変な値が入ります。デモのように CSS.supports() で弾く一手間を、僕は必ず入れるようにしました。
よくある質問
Q. CSS変数とSassの変数($color)は何が違いますか。
A. Sassの変数はビルド時に固定値へ展開されて消えます。CSS変数はブラウザ実行時に生き続けるので、JSから書き換えたり、ダークモードで差し替えたりできます。「動的に変えたいならCSS変数、ビルド時だけならSass」で使い分けます。
Q. 古いブラウザでも使えますか。
A. モダンブラウザはすべて対応済みです。IE11だけ非対応ですが、var() のフォールバックを書いておけば、未対応環境では代替値で表示されるので大崩れしません。
Q. --名前 の命名にルールはありますか。
A. 大文字小文字は区別されます(--Color と --color は別)。揺れると事故るので、小文字+ハイフン区切りに統一するのがおすすめです。--color-accent のように「カテゴリ-役割」で並べると、数が増えても探しやすくなります。
Q. JSで読んだ値に空白が混じります。
A. getPropertyValue() は前後に空白を含むことがあります。.trim() を付ければ解決します。色を比較するときにこれで一度ハマるので、最初から付けておくと安全です。
Q. ダークモードで文字と背景は変えたのに、なぜか野暮ったいです。
A. 枠線と影が取り残されているはずです。--border と影の値もテーマごとに見直してください。背景・文字だけ変えると、薄い境界線や黒い影が残ってUIが濁ります。
まとめ:実際に試した結果
CSS変数は、色を置き換える小技ではなく、見た目の「正解の置き場所」を1か所に集める仕組みです。--名前 で定義し、var(--名前, 代替値) で読み、:root でグローバルに、セレクタでスコープ付きに。JSからは setProperty() で書き換える。要点はこれだけです。
冒頭の「14か所探し」以来、僕は色を直接書くのをやめました。アクセント色を --color-accent の1行にまとめたら、キャンペーンの色替えは数秒で終わるようになりました。カラーピッカーの即時反映も setProperty() 1行で動きました。そして一番効いたのは、フォールバックを入れる癖です。「真っ黒画面」で30分溶かす日は、それ以来一度も来ていません。
仕様を深掘りするならMDNのCSSカスタムプロパティガイドが一次情報として正確です。トークンを階層で整理して長く運用する話はデザイントークン設計へ、UI実装の依頼文をそのまま使いたい場合は研修・相談も覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。