【CSS変数 vs SCSS変数】実務での使い分けとハイブリッド運用のベストプラクティス

SCSS を書いていると、色も余白もブレイクポイントも $primary で足りてしまいます。一方で、最近の実装例やコンポーネントライブラリでは var(--color-primary) が並ぶ。どちらが正しくて、どちらを捨てるべきなのか。結論から言うと、どちらか一方に寄せる必要はありません。
迷いの正体は、見た目が似ていることです。どちらも「名前を付けた値」ですが、解決されるタイミングが違います。SCSS変数はコンパイル時、CSS変数(カスタムプロパティ)は実行時です。この一点が分かると、ダークモードは CSS変数、ブレイクポイントは SCSS変数、という現場の使い分けが自然に見えます。
この記事では、フリーランスのWebディレクター兼エンジニアとして、HTML / CSS / SCSS、Next.js、WordPress の実装を見てきた立場から、両者の違いとハイブリッド運用を整理します。文法の紹介で終わらせず、動かない書き方と動く書き方を並べます。読み終えたら、既存の $変数 を全部 var(-- ) に置き換える前に、どこを残すべきか判断できるようにしてください。
コンパイル時と実行時では、変数の意味が違う
SCSS変数は、Sass(Dart Sass など)が CSS を生成するときに展開されます。ビルドが終わった時点で、名前は消えています。ブラウザは $bk_md を知りません。
CSS変数は、生成後の CSS に残ります。:root や要素に紐づき、継承し、JavaScript やクラスの付け替えで後から変えられます。
// SCSS変数:コンパイル時に値が確定する
$bk-md: 768px;
$color-primary: #160076;
.hero {
color: $color-primary;
}
@media (min-width: $bk-md) {
.hero {
padding: 5rem 0;
}
}コンパイル後は、おおよそ次のような CSS になります。
.hero {
color: #160076;
}
@media (min-width: 768px) {
.hero {
padding: 5rem 0;
}
}CSS変数は、名前が残ります。
:root {
--color-primary: #160076;
--space-section: 3rem;
}
.hero {
color: var(--color-primary);
padding: var(--space-section) 0;
}
[data-theme="dark"] {
--color-primary: #c4b8ff;
--space-section: 3rem;
}[data-theme="dark"] を付け替えると、.hero のルールを増やさなくても色が変わります。これが実行時に解決する、という実務的な意味です。
このサイトの SCSS でも、ブレイクポイントは $bk_md: 768px として mixin に渡し、ブランドカラーは $forth-color としてコンパイル時に展開しています。テーマ切替が無い画面では、この割り切りで十分です。ダークモードや、コンポーネント単位の上書きが必要になった時点で、色だけ CSS変数へ出す、という順が安全です。
機能と性能の比較
「速い/遅い」で選ぶより、できること/できないことで選ぶ方が事故が減ります。CSS変数の再計算コストは、通常のサイトのテーマ切替では問題になりにくいです。ボトルネックを変数のせいにする前に、画像とレイアウトを見た方がよいです。
項目 | SCSS変数 | CSS変数 |
|---|---|---|
解決のタイミング | コンパイル時 | 実行時(算出値) |
生成後のCSSに残るか | 残らない | 残る |
継承・カスケード | しない(ファイル/スコープ) | する |
JSから変更 | できない |
|
| 使える | 使えない |
Sassの色関数に渡せるか | 使える | コンパイル時には使えない |
フォールバック | 未定義ならビルドエラーになりやすい |
|
主な用途 | 設計トークンの計算、BP、mixin | テーマ、上書き、動的な見た目 |
性能について断定できるのは次だけです。
- SCSS変数は、実行時コストが無い(そもそも存在しない)
- CSS変数は、値が変わった要素以下で、その変数を使っているプロパティが再計算される
- だからといって、色や余白のトークンを CSS変数にすることが表示の主因になるケースは稀
実務での使い分けルール
迷ったら、この3行で足ります。
- 実行中に変えたい値は CSS変数(テーマ、状態、コンポーネントの上書き)
- ビルド時に計算して終わりたい値は SCSS変数(ブレイクポイント、z-index の段階、Sass関数の入力)
- 両方必要な値は、SCSSで計算して
:rootに書き出す(ハイブリッド)
CSS変数に向くもの
ダークモード、ブランドカラーの切替、カードだけ色を変える、といった 同じセレクタを量産したくない見た目 です。
:root {
--color-bg: #f6f5f2;
--color-fg: #161513;
--color-accent: #160076;
}
[data-theme="dark"] {
--color-bg: #161513;
--color-fg: #f6f5f2;
--color-accent: #c4b8ff;
}
body {
background: var(--color-bg);
color: var(--color-fg);
}
.button {
background: var(--color-accent);
color: #fff;
}JavaScript から触る場合も、クラスを付ける方が管理しやすいです。直接書き換えるなら、カスタムプロパティ名だけを対象にします。
document.documentElement.dataset.theme = "dark";
// または1トークンだけ変える場合
document.documentElement.style.setProperty("--color-accent", "#160076");prefers-color-scheme と組み合わせるなら、OS の設定を初期値にし、ユーザー操作で data-theme が勝つ、という順が分かりやすいです。
:root {
--color-bg: #f6f5f2;
--color-fg: #161513;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg: #161513;
--color-fg: #f6f5f2;
}
}SCSS変数に向くもの
メディアクエリの条件、Sassの関数・mixin の引数、マップで管理する段階値です。ブレイクポイントを CSS変数にすると、後述するとおり @media (min-width: var(--bp)) が動きません。
$bk-md: 768px;
$bk-lg: 1024px;
$z-header: 100;
$z-modal: 200;
@mixin mq-md {
@media (min-width: $bk-md) {
@content;
}
}
.header {
z-index: $z-header;
@include mq-md {
padding-inline: 2rem;
}
}余白の計算を rem() のような関数に載せる場合も、入力はコンパイル時に分かっている必要があります。実行時の var(--font-size) を Sass の割り算に渡せません。
ハイブリッドが現場の本命
色は実行時に切り替えたい。ただしホバー色は、設計段階で少しだけ暗くしたい。そのときは SCSSで計算し、結果だけ CSS変数にする のが安定します。
@use "sass:color";
$bk-md: 768px;
$color-primary: #160076;
$colors: (
primary: $color-primary,
primary-dark: color.adjust($color-primary, $lightness: -8%),
text: #444,
);
:root {
@each $name, $value in $colors {
--color-#{$name}: #{$value};
}
}
.button {
background: var(--color-primary);
}
.button:hover {
background: var(--color-primary-dark);
}
@media (min-width: $bk-md) {
:root {
--space-section: 5rem;
}
}ポイントは2つです。
$bk-mdは クエリ条件 に使う(SCSS)--space-sectionは クエリの中で値を上書きする(CSS変数)
「メディアクエリで CSS変数が使えない」と「メディアクエリの中で CSS変数を変える」は、別の話です。後者は問題なく使えます。
悪い使い方と、改善した使い方
全部を CSS変数にしたくなる気持ちは分かります。トークンがブラウザに残るので、DevTools で追いやすいからです。ただし、コンパイル時にしか解けない処理まで寄せると壊れます。
悪い例:ブレイクポイントまで実行時に寄せる
:root {
--bk-md: 768px;
--color-primary: #160076;
}
// これは期待どおり動きません
@media (min-width: var(--bk-md)) {
.hero {
padding: 5rem 0;
}
}
.button:hover {
// コンパイル時に var(--color-primary) の中身が分からない
background: color.adjust(var(--color-primary), $lightness: -8%);
}@media の条件は、要素のカスケードを見ません。そのため var(--bk-md) は条件として解決されません。Sass の color.adjust も、コンパイル時に色が分からないと計算できません。古い darken() でも同じです。
良い例:条件と計算はSCSS、見た目の上書きはCSS変数
@use "sass:color";
$bk-md: 768px;
$color-primary: #160076;
:root {
--color-primary: #{$color-primary};
--color-primary-dark: #{color.adjust($color-primary, $lightness: -8%)};
--hero-padding-y: 3rem;
}
@media (min-width: $bk-md) {
:root {
--hero-padding-y: 5rem;
}
}
.hero {
padding: var(--hero-padding-y) 0;
color: var(--color-primary);
}
.button:hover {
background: var(--color-primary-dark);
}実行時にさらに暗くしたい場合は、Sass を使わず CSS 側で混ぜます。color-mix() は比較的新しい関数なので、対象ブラウザを確認してから使ってください。
.button:hover {
background: color-mix(in srgb, var(--color-primary) 82%, black);
}実務でそのまま使える3つの型
1. Web制作:ダークモードはトークンだけ切り替える
コンポーネントごとに .card--dark を増やすより、背景・文字・アクセントを変数にまとめた方が、修正箇所が減ります。ボタンやリンクは var(--color-accent) を参照するだけにします。
.card {
background: var(--color-bg);
color: var(--color-fg);
border: 1px solid color-mix(in srgb, var(--color-fg) 12%, transparent);
}テーマは :root と [data-theme] の2段に閉じます。コンポーネントファイルにハードコードした #160076 が増えると、切替漏れが起きます。
2. 実装:既存のSCSS設計を壊さず、色だけ吐き出す
Next.js で SCSS モジュールを使っている場合、いきなり全ファイルを CSS変数へ書き換えない方が安全です。先に variables.scss の色を :root へ複製し、新しいコンポーネントから var(-- ) を参照します。ブレイクポイントの mixin はそのまま残します。
$forth-color: #160076;
$third-color: #444;
:root {
--color-accent: #{$forth-color};
--color-text: #{$third-color};
}既存クラスの color: $forth-color; は、急いで書き換えなくても表示は同じです。テーマが必要になった画面から、参照を var(--color-accent) に切り替えます。
3. コンポーネント:親がトークンを上書きする
カードのアクセントだけ変えたいときに、新しい BEM 修飾子を増やし続けない、という使い方です。CSS変数の継承がここで効きます。
.card {
--card-accent: var(--color-accent);
border-top: 4px solid var(--card-accent);
}
.card.is-featured {
--card-accent: #b45309;
}SCSS変数では、親要素からの上書きができません。同じ見た目をやろうとすると、修飾子の数だけプロパティをコピーすることになります。
現場でハマりがちな注意点
Sass関数とCSS変数は、タイミングが噛み合わない
color.adjust、color.scale、古いコードに残る darken() / lighten()、mix()、自前の rem() は、いずれもコンパイル時です。var(--color-primary) を渡しても計算できません。計算したいなら、SCSS変数に対して実行し、結果を CSS変数へ書き出します。
Dart Sass では、グローバルな darken() は非推奨寄りの扱いです。新規なら sass:color モジュールを使ってください。既存の darken() が残っていても、すぐ消えるわけではありませんが、警告は出ます。
@media の「条件」と「中身」を混同しない
$bk-md: 768px;
// NG:条件にCSS変数
@media (min-width: var(--bk-md)) { }
// OK:条件はSCSS変数
@media (min-width: $bk-md) {
:root {
--hero-padding-y: 5rem; // OK:中身の上書き
}
}コンテナクエリの条件も、要素のカスタムプロパティを長さ条件に直接は使えません。クエリで切り替える対象は、中のトークンにする、と考えてください。
フォールバックは「前の行」ではなく var() の第2引数
カスタムプロパティの値が無効なとき、同じルール内の直前の宣言に戻るとは限りません(invalid at computed-value time)。フォールバックは次のように書きます。
.button {
background: var(--color-accent, #160076);
}未定義と、空文字や不正値が入ることでは挙動が違います。デバッグするときは DevTools で --color-accent の算出値を見てください。
変数を増やしすぎて、目的が分からなくなる
--c1 や --sp のような短い名前は、DevTools では追えても、3ヶ月後の差分レビューで止まります。--color-accent と --space-section のように、役割が名前で分かる粒度にします。全部を実行時変数にしない、という判断も、この可読性のためです。
「CSS変数にすればモダン」が目的になると壊れる
CSS変数は、実行時の上書きのための仕組みです。知能やパフォーマンスを自動で上げるスイッチではありません。テーマが無い、JS から触らない、メディアクエリの条件に使う、この3つのどれかに当てはまる値は、SCSSのままが扱いやすいです。
確認に使う資料とツール
仕様の一次情報は、MDN の Using CSS custom properties と、Sass の Variables です。@media で var() が使えない理由も、カスタムプロパティが要素のカスケードに紐づく、という説明とセットで読むと腹落ちします。
実装中は、ブラウザの DevTools で :root の算出値を見ると、SCSS変数では不可能な確認ができます。Cursor のようなエディタでリファクタする場合も、「ブレイクポイントは触らない」「色トークンだけ :root へ出す」と制約を先に書いた方が、全置換を防げます。
まとめ:使い分けは、変えるタイミングで決める
CSS変数と SCSS変数は、名前の付け方が似ているだけで、役割が違います。
- SCSS変数は、コンパイル時に消える設計用の値
- CSS変数は、ブラウザが持ち続ける実行時の値
- ダークモード・コンポーネント上書き・JS連携は CSS変数
- ブレイクポイント・Sass関数・z-index の段階は SCSS変数
- 両方必要なら、SCSSで計算して
:rootに書き出す
既存コードを一気に置き換える必要はありません。次にテーマや状態で色を変える画面が出たとき、その色だけ CSS変数にする。それだけで、ハイブリッド運用は始まります。
サイトのトークン設計や、SCSSから CSS変数への段階移行を一緒に整理したい場合は、相談・お問い合わせからどうぞ。実装中のファイル構成のままで構いません。