Last updated on

CSSコンポーネントのバリアントは data 属性と CSS 変数で切り替える

  • SCSS
  • Sass
  • CSS
  • コンポーネント設計
  • BEM

見た目の差分を出すには、次のような手段があります(併用も普通にあります)。

  • 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-sizedata-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 を残す範囲を決めるのがよいと思います。