SCSSを単なるプリプロセッサとして使う時代は終わりました。
この記事では、モダンCSSの機能を最大化し、UIの意図(Semantic)を記述するための宣言型設計ツールとしてCSSを活用する新しいアプローチを提唱します。私はこの設計思想を SVOC-CSS(Semantic Variables over Classes CSS) と呼んでいます。
AI全盛期の今だからこそ、この設計思想が重要になります。生成AIにUIコンポーネントのコードを書かせた際、冗長で保守性の低いコードが出力されてうんざりした経験はないでしょうか。SVOC-CSSは、そうした前時代的な設計から脱却し、HTMLとCSSをクリーンに保つための実践的なCSSの思想です。
github gistにSVOC-CSSのrule.md をおいてあります。これをantigravity等の.agent/rulues/svoc-css.md等においておいてあとから参照すればこのスタイルが自動で厳守されます。
AIが生成しがちな「前時代的なコード」の限界
最近の生成AIにボタンなどのコンポーネントを作らせると、BEMのモディファイアとユーティリティクラスが入り混じった、以下のような見るも嫌になるコードをよく出力します。
<!-- よくある生成AIが書いた保守性の低いマークアップ -->
<button class="btn btn--primary btn--large btn--loading bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded">
送信中...
</button>
// 状態が増えるたびに肥大化するCSS
.btn {
//基本スタイル
}
.btn--primary {
background-color: #3b82f6;
color: white;
}
.btn--primary:hover {
background-color: #2563eb;
}
.btn--large {
padding: 1rem 2rem;
font-size: 1.25rem;
}
.btn--loading {
opacity: 0.7;
pointer-events: none;
}
誤解のないように言えば、AI(LLM)にとってTailwindやBEMのように「要素に対して直接スタイルを当てはめていく」パラダイムは非常に思考しやすいモデルです。そのため、プロトタイプを素早く構築するフェーズにおいて、この出力結果は理にかなっています。
しかし、このコードをそのままプロダクトに組み込むのは危険です。状態(色、サイズ、ローディングなど)の組み合わせが増えるたびに新しいクラス名が必要になり、HTMLは属性値で埋め尽くされます。CSS側も、要素を直接上書きするコードが散乱し、詳細度の競合が発生しやすくなります。結果として、プロトタイプとしては優秀でも、保守性は最悪な状態に陥ります。
そこでこの記事では、 AIに爆速で書かせたBEMやTailwindベースのコードを、AI自身に丸投げして保守性の高い「SVOC-CSS」へアップコンバートして組み込む というワークフローを前提に解説を進めます。
CUBE CSSの思想を継承した「状態の合成」
SVOC-CSSの根底にあるのは、CUBE CSSの設計思想をベースに、CSS変数とモダンCSS(:hasや@layerなど)による状態管理を徹底させるというアプローチです。
私はHTMLのclass属性を、BEMの--modifierのような単一の識別子を置く場所とはみなしません。クラスは、エンティティの「状態を合成する場」として機能します。
同じボタンを実装し、旧来の設計と比較してみます。命令的なクラス名ではなく、状態を宣言してCSS変数で制御することで、マークアップとスタイルの関係性が劇的に変わります。
<!-- SVOC-CSSのクリーンなマークアップ -->
<button class="btn is-primary is-large is-loading">
送信中...
</button>
/* * 変数を中心に据えたSVOC-CSSの実装
* * 【CUBE CSSとの対応関係】
* - Block (基本) : `.btn` 自身が持つ基本形状
* - Exception (例外) : `.is-*` クラスによる状態変化
* - State Carrier (仲介役) : 両者を繋ぐ `--btn-*` 変数群
* (※Utilityはグローバルな余白調整クラスなどに分離します)
*/
.btn {
// 1. 変数の初期化(インターフェースの定義)
// ここでBlockの初期状態を宣言し、仲介役(変数)を準備します
--btn-bg: var(--color-gray-200);
--btn-text: var(--color-gray-800);
--btn-padding-block: 0.5rem;
--btn-padding-inline: 1rem;
--btn-opacity: 1;
// 2. プロパティの適用(変数のみを参照)
// Blockの基本スタイルは、直接値を書かずに変数を参照します
background-color: var(--btn-bg);
color: var(--btn-text);
padding-block: var(--btn-padding-block);
padding-inline: var(--btn-padding-inline);
opacity: var(--btn-opacity);
transition: all 0.2s ease;
// 3. 状態の連鎖による変数の再定義
// ここがExceptionにあたります。クラスはプロパティを直接書き換えず、
// 仲介役(変数)の値を上書きする役割に徹します
&.is-primary {
--btn-bg: var(--color-blue-500);
--btn-text: var(--color-white);
&:hover {
--btn-bg: var(--color-blue-600);
}
}
&.is-large {
--btn-padding-block: 1rem;
--btn-padding-inline: 2rem;
}
&.is-loading {
--btn-opacity: 0.7;
pointer-events: none;
}
}
変数の衝突を防ぐ、グローバルとローカルの厳格な分離
変数を多用する設計において、「変数名が衝突して管理不能になるのではないか」という懸念を持たれるかもしれません。SVOC-CSSでは、変数を役割に応じて2つのレイヤーに厳格に分離し、命名規則を設けることでこの問題を解決します。
1つ目は、サイト全体のテーマ(色や余白など)を管理する**グローバル変数(Design Tokens)**です。これらは:rootに定義し、--color-primaryや--space-mdのように「概念」をプレフィックスとします。
2つ目は、コンポーネント固有の状態を管理する ローカル変数(State Carriers) です。これらはコンポーネントの基本クラス内に閉じ込め、--btn-bgのように「コンポーネント名」をプレフィックスとします。
重要なのは、コンポーネントを定義する際、ローカル変数の初期値として必ずグローバル変数を参照させる「バケツリレー」を行うことです。
// 0. グローバル変数 (サイト全体のテーマ)
:root {
--color-blue-500: #3b82f6;
--color-white: #ffffff;
}
.btn {
// 1. ローカル変数の初期化 (グローバル変数をバケツリレーで受け取る)
--btn-bg: var(--color-blue-500);
--btn-text: var(--color-white);
// 2. プロパティへの適用 (ローカル変数のみを参照する)
background-color: var(--btn-bg);
color: var(--btn-text);
}
SCSSの表現力を引き出す最適なパートナー
SVOC-CSSは、SCSSの強力なネスト機能を最も効果的に引き出せる設計です。
BEMでネストを多用するとセレクタの構造が複雑になりがちですが、SVOC-CSSでは「ブロックの中に状態を閉じ込める」という構成を自然に記述できます。
アンパサンド(&)を使って状態クラス(.is-*)を連結することで、そのコンポーネントがどのような状態を持ち、それぞれで変数がどう変化するのかを一箇所に集約できます。また、@useによるトークン管理とも親和性が高く、定義レイヤーと適用レイヤーを明確に分けるSCSSの記述スタイルと非常にマッチします。
ネストの深さを制御する構造的な規律
SVOC-CSSでは、SCSSのネスト機能の乱用を防ぐための明確なルールを設けています。原則として、ネスト(子孫セレクタ)の深さは最大3階層までとします。4階層以上になる場合は設計を見直し、新しいコンポーネントとして切り出します。
ネストが深くなると、HTMLのDOM構造にCSSが強く依存してしまい、再利用性が著しく低下するためです。また、意図しない詳細度の上昇を招き、スタイルの上書きが困難になります。
ただし、以下のケースはネストの深さとしてカウントしません。
- 状態クラスの連結(
&.is-や&.has-) - 擬似クラスや擬似要素(
&:hover、&::beforeなど) - メディアクエリ(
@media)
これらは特定の要素への「子孫関係(DOMの階層)」を表すものではなく、コンポーネント「自身の状態や条件の変化」を記述するものです。HTMLの構造への依存度を上げないため、ネスト制限の例外として扱います。
詳細度のインフレを防ぐ設計
マルチクラスを多用すると、詳細度のインフレが発生しやすくなります。これを技術的に防ぐため、モダン擬似クラスを活用します。
詳細度を0に保ちたいベースラインのスタイルには:where()を使用します。これにより、外部からの上書きが容易になります。また、複雑な状態の合成には:is()を使い、記述を簡潔に保ちながら詳細度をコントロールします。
// ベーススタイルは常に外部から上書き可能にします
:where(.action-card) {
padding: 1.5rem;
border-radius: var(--radius-md);
}
// 状態の合成を簡潔に記述します
.action-card {
&:is(.is-disabled, [aria-disabled="true"]) {
--card-bg: var(--color-gray-100);
cursor: not-allowed;
}
}
親子関係のロジックを逆転させる
従来は、親要素が子要素のスタイルを支配する設計が主流でした。SVOC-CSSでは:has()を活用し、子の状態を親が検知して自己変容する双方向の設計へ移行します。
たとえばフォームグループの実装では、内部の入力要素がフォーカスされたりエラー状態になったりした際、親要素自身が変数を書き換えてスタイルを変化させます。
.form-group {
--group-accent-color: var(--color-gray-500);
border-inline-start: 4px solid var(--group-accent-color);
// 子要素の状態を検知して親の変数を書き換えます
&:has(.input:focus) {
--group-accent-color: var(--color-primary);
}
&:has(.input.is-invalid) {
--group-accent-color: var(--color-danger);
}
}
JavaScriptで親要素にクラスを付け外す手間が省け、マークアップとスタイルの責務が明確に分離されます。
レイヤーによる構造的な規律
大規模開発でのスタイルの上書き合戦を構造的に解決するために、カスケードレイヤー(@layer)を導入します。SCSSのネストの深さよりも、どのレイヤーに属しているかを重視してスタイルの優先順位を決定します。
@layer reset, base, components, states, utilities;
@layer components {
.btn {
padding-block: 0.5em;
padding-inline: 1em;
}
}
@layer states {
// 状態による変化は基本形状より必ず優先させます
.btn.is-loading {
--btn-opacity: 0.7;
pointer-events: none;
}
}
論理プロパティとモジュールの活用
物理的な上下左右に依存するプロパティは使用しません。書字方向に依存しない論理プロパティ(Logical Properties)を採用し、多言語対応やレイアウトの柔軟性を担保します。
また、グローバル変数の使用を廃止します。@useを使って明示的な名前空間でトークンを管理し、スタイルのカプセル化を促進します。
@use "tokens" as t;
.card {
/* margin-top/bottomの代わりに論理プロパティを使用します */
padding-block: t.$space-md;
padding-inline: t.$space-lg;
}
唯一の代償である「コード量の増加」をどう捉えるか
ここまでながーく提唱してきましたが、残念ながらSVOC-CSSには明確なトレードオフが存在します。それは、変数の定義とプロパティへのバケツリレーを必ず行うため、CSSの物理的なコード量がBEMやTailwindと比較して圧倒的に増えることです。
しかし、現代のフロントエンド開発においてこの増加は実用上の問題になりません。
まず挙げられるのが、圧縮アルゴリズムの恩恵です。
--btn-bgやvar(...)といった定型句が何度繰り返されても、GzipやBrotliなどのサーバーサイド圧縮が反復パターンを極限まで小さくしてくれます。ソースコードの行数が増えても、ネットワーク越しにユーザーへ届くファイルサイズは驚くほど変わりません。
次に、AIとエディタによるタイピングコストの消滅です。
変数による状態管理は、人間がすべて手打ちするには確かに苦痛です。しかし、AIやGitHub Copilotにとってこの「規則正しく予測可能なバケツリレー」は、最も得意とするパターンのひとつです。私たちが最初の変数を定義すれば、あとはエディタが一瞬で完璧に補完してくれます。
そして何より重要なのが、AI自身の「認知負荷の低さ」と「推論の安定性」です。
AIにとって、コードの物理的な行数が多いことは処理の負担にはなりません。AIが最も苦手とし、予期せぬバグを生み出す原因となるのは「隠れた依存関係」や「グローバルな状態の予測」です。
BEMやユーティリティクラスが複雑に絡み合ったコードでは、変更の波及範囲を推論するために広大なコンテキストを維持する必要があります。一方、SVOC-CSSのように変数がカプセル化されている構造は、AIにとって厳格な型定義のように機能します。
局所的かつ確定的にコードを解釈できるため、AIが文脈を見失わず、大規模開発においても極めて安全に保守し続けることができます。
つまり私たちは、「物理的なタイピングの手間」というコストをすべてAIに丸投げし、その代わりに「人間とAIの双方が、数ヶ月後に読んでも絶対に破綻しない予測可能な強固な構造」を手に入れたことになります。
コードを書くコストよりも、後からコードを読む際の認知負荷をゼロにすることのほうが、圧倒的に価値が高いのです。
AIと協働するためのプロトコル
生成AIをコーディングパートナーとして活用する際は、SVOC-CSSの原則から逸脱しないよう以下のプロトコルを適用します。
- 状態クラス内でプロパティを直接書き換えず、CSS変数の再定義を行うよう指示する
- グローバル変数(テーマ)とローカル変数(状態)を分離し、ローカル変数の初期値にグローバル変数を参照するバケツリレーを指示する
- 子要素のスタイル変更のためにJavaScriptで親のクラスを操作する実装を見つけたら、
:has()への置き換えを検討する - ネストは原則3階層までとし、超過する場合はコンポーネントの分離や
:is()による詳細度リセットを提案する - 物理プロパティ(leftやrightなど)が混入した場合は論理プロパティへ修正する
- コンポーネントと修飾子が競合している場合は、
@layerによる優先順位付けを行う
おわりに
SVOC-CSSアーキテクチャにおいて、クラス名は単なる見た目のスイッチではありません。
.is-loadingというクラスは「半透明にする」という直接的な命令ではなく、「このエンティティは現在『読込中』という状態である」という事実の宣言です。その事実をCSS変数が媒介し、各プロパティがコンテキストに従って最適に振る舞います。
クラスによる支配(Classes)よりも、意味的な変数(Semantic Variables)を優先する。このアプローチこそが、モダンフロントエンドにおける最も保守性の高いCSS設計につながると確信しています。