見た目の差分を出すには、次のような手段があります(併用も普通にあります)。
- BEM の modifier(
--largeなど)をクラスに足す - 親ブロックやセクション(
.p-heroなど)のセレクタから、子のコンポーネント用 CSS 変数やプロパティを上書きする - 子孫セレクタやメディアクエリで、同じブロック内の見た目だけ変える
- カスタムデータ属性(
data-*)を付け、属性セレクタでスタイルを切り替える - (必要なら)インラインスタイルやユーティリティクラスで局所的に寄せる
「バリアント設計」と呼ばれるのは、主に HTML にどう意図を載せ、SCSS のどこに差分を集約するか の話です。
この記事では、そのうち 同一コンポーネントのまま アイコンやサイズなどが変わる例として、アイコンの違うボタン(data-icon=)を軸に、自分が今どうしているか と メリット・デメリット を整理します。
自分はこうしている
バリアントの指定は原則 data-*
軸ごとに属性を分けます。たとえばサイズとアイコン種別なら、次のように 別の属性 にします。
<button type="button" class="c-button" data-size="lg" data-icon="search">
検索
</button>
値は 用途が名前からわかる語 にします。data-size="2" のように番号だけにすると、マークアップを読んだだけでは意図が伝わりにくいので避けています。アイコン種別も data-icon="search" のように 語で固定 します。
実際の数値やアイコンなどの差分は CSS 変数に寄せる
data-size="lg" や data-icon="search" を付けたときに 何ピクセルにするか・アイコンに何を出すか は、ブロック側で CSS 変数を上書きし、子(疑似要素や内側の要素)は var() で参照する形にしています。
.c-button {
--font-size: #{rem(14)};
--padding-inline: #{rem(12)};
--icon-mask: none; // デフォルトはアイコンなし、など
}
.c-button[data-size="lg"] {
--font-size: #{rem(16)};
--padding-inline: #{rem(16)};
}
.c-button[data-icon="search"] {
--icon-mask: url('/assets/icon-search.svg'); // mask-image 用の SVG など
}
.c-button[data-icon="close"] {
--icon-mask: url('/assets/icon-close.svg');
}
.c-button__label {
font-size: var(--font-size);
}
.c-button::before {
content: "";
mask-image: var(--icon-mask);
// `none` のときは非表示にするなど、プロジェクトの置き方に合わせる
}
変数は コンポーネントのルートでデフォルトを定義し、親のセクション(.p-hero など)から 同じ変数名 で上書きできるようにしておきます。インラインスタイルで値を渡し続ける運用は、どこに定義があるか散らばりやすいので使っていません。
軸を混ぜない
サイズ・アイコン種別・開閉状態など 別の関心事 を、1 つの data-variant="lgSearchOpen" のように 1 値に詰め込まない ようにしています。詰め込むと、あとから「サイズはそのまま、アイコン種別だけ変えたい」のような組み合わせが必要になったときに破綻しやすいからです。
運用メモ
- 取りうる
data-*の組み合わせは、該当 SCSS の冒頭にコメントで一覧を残すようにしています。あとから自分や他の人が種類を追いやすくなります。 - 複数の
data-*セレクタを重ねると 詳細度が積み上がる ので、上書きがつらくなったら:where()で詳細度を抑える、という逃げ道もメモしておく程度にしています。
メリット
- 軸を分けておけるので、
data-sizeとdata-iconを独立して組み合わせられます。HTML が読みやすく、あとからバリアントが増えても破綻しにくいです。 - 見た目の数値は CSS 変数に集約できるので、子要素のセレクタを増やしすぎずに済みます。親コンテキスト(セクション)から同じ変数名で上書きするだけ、という流れも作りやすいです。
js-プレフィックスのクラスとは役割が分かれるので、「JS 用」と「見た目のバリアント用」が混ざりにくいです(スタイルをjs-に書かない、というルールとも相性がよいです)。- テンプレート側で 属性名を HTML にそのまま出しているなら、プロジェクト内検索で
data-sizeなどを辿りやすいです。
デメリット・注意点
- マークアップがやや冗長になります。クラス1個と modifier1個で済んでいたところに、属性が複数並びます。
- チームで属性名の約束事が必要です。
data-variantに全部詰めるのと、data-size/data-iconのように軸で分けるのでは、可読性と拡張性が変わります。迷ったら軸分割を優先する、と決めておくとよいです。 data-*を複数セレクタで重ねると詳細度が上がるので、後から上書きしたい別コンポーネントと競合することがあります。そのときは:where()やセレクタの切り方を見直す必要が出ます。- 既存コードが BEM modifier 中心のプロジェクトでは、テンプレートやパーシャルの引数設計を変えるコストがかかります。いきなり全置換せず、新規コンポーネントから寄せていく形が現実的です。
- アクセシビリティの意味での「状態」は、
aria-*やhiddenなど 適切な属性と役割分担が必要です。見た目用のdata-activeと、実際の開閉状態をaria-expandedで表す、のように分ける場面もあります。
まとめ
バリアントは 「HTML では軸ごとの data-*」「見た目の中身は CSS 変数」 に寄せると、組み合わせと保守のバランスが取りやすい、というのが今の自分の結論です。
デメリットは主に 冗長さ・詳細度・移行コスト なので、プロジェクトの規模と既存の書き方に合わせて、modifier を残す範囲を決めるのがよいと思います。