テクノロジー

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

【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変数 $name

CSS変数 var(--name)

解決のタイミング

コンパイル時

実行時(算出値)

生成後のCSSに残るか

残らない

残る

継承・カスケード

しない(ファイル/スコープ)

する

JSから変更

できない

setProperty でできる

@media の条件に使えるか

使える

使えない

Sassの色関数に渡せるか

使える

コンパイル時には使えない

フォールバック

未定義ならビルドエラーになりやすい

var(--a, #333) で指定できる

主な用途

設計トークンの計算、BP、mixin

テーマ、上書き、動的な見た目

性能について断定できるのは次だけです。

  • SCSS変数は、実行時コストが無い(そもそも存在しない)
  • CSS変数は、値が変わった要素以下で、その変数を使っているプロパティが再計算される
  • だからといって、色や余白のトークンを CSS変数にすることが表示の主因になるケースは稀

実務での使い分けルール

迷ったら、この3行で足ります。

  1. 実行中に変えたい値は CSS変数(テーマ、状態、コンポーネントの上書き)
  2. ビルド時に計算して終わりたい値は SCSS変数(ブレイクポイント、z-index の段階、Sass関数の入力)
  3. 両方必要な値は、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.adjustcolor.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 です。@mediavar() が使えない理由も、カスタムプロパティが要素のカスケードに紐づく、という説明とセットで読むと腹落ちします。

実装中は、ブラウザの DevTools で :root の算出値を見ると、SCSS変数では不可能な確認ができます。Cursor のようなエディタでリファクタする場合も、「ブレイクポイントは触らない」「色トークンだけ :root へ出す」と制約を先に書いた方が、全置換を防げます。

まとめ:使い分けは、変えるタイミングで決める

CSS変数と SCSS変数は、名前の付け方が似ているだけで、役割が違います。

  • SCSS変数は、コンパイル時に消える設計用の値
  • CSS変数は、ブラウザが持ち続ける実行時の値
  • ダークモード・コンポーネント上書き・JS連携は CSS変数
  • ブレイクポイント・Sass関数・z-index の段階は SCSS変数
  • 両方必要なら、SCSSで計算して :root に書き出す

既存コードを一気に置き換える必要はありません。次にテーマや状態で色を変える画面が出たとき、その色だけ CSS変数にする。それだけで、ハイブリッド運用は始まります。

サイトのトークン設計や、SCSSから CSS変数への段階移行を一緒に整理したい場合は、相談・お問い合わせからどうぞ。実装中のファイル構成のままで構いません。