ドキュメントサイトを保守しているとします。トップページの記事カードには細い枠線、角丸、余白があり、隣のバージョン通知にもほぼ同じ枠があります。数行の CSS をコピーして枠線の色を変えれば、二つのブロックができあがります。

数週間後、サイト全体のコンテナの間隔を調整することになりました。カードは直したのに、通知枠を一か所見落とします。こんなとき、「最初に mixin にしておけば忘れなかった」と考えたくなります。

しかし、逆の状況もあり得ます。通知はもっとコンパクトにしたい一方、カードには読みやすい余白を残したい。二つが同じ抽象化に結びついていたら、一度の修正が変えるべきでない場所へ波及します。

重複コードは手がかりを与えてくれますが、その先には判断が必要です。このスタイルは同じルールを表していて、今後も一緒に変わるべきでしょうか。 ネイティブ CSS の @mixin が注目に値するのは、スタイルルールのまとまりに名前を付け、使う場所で違いを渡せる可能性があるからです。

一緒に変えるべきルールを先に見つける

この架空のドキュメントサイトの要件を、もう少し具体化しましょう。記事カードとバージョン通知には控えめな枠が必要で、角丸は共通です。枠線と見出しには同じアクセントカラーを使い、通知の余白は少し狭くできます。

二種類のコンテナは内容が異なりますが、視覚的なルールの一部を共有しています。問題は数行の重複をどう消すかだけではありません。どの決定を共通の場所に置き、どれを個別のコンポーネントに残すかです。

例えば角丸はサイト全体の外観上の約束として集中管理できますが、内側の余白は情報密度に関わるかもしれません。カードに概要を表示するか、通知に操作リンクが必要かは、それぞれの責任です。共通の枠のために、そこまで一緒にする必要はありません。

この区別が抽象化の大きさを決めます。「何が共通なのか」さえ説明できない段階では、数行の重複を残すほうが調整しやすいこともあります。使われ方が見えてから、本当に安定した部分をまとめる根拠が得られます。

カスタムプロパティと共通 class でどこまでできるか

まず mixin を使わずに考えます。以下の HTML は、後の例でも共通して使う構造です。

<article class="site-card">
  <h2 class="site-panel-title">Installation guide</h2>
  <p>Start with your project's first page.</p>
</article>
 
<aside class="site-notice">
  <h2 class="site-panel-title">Release notice</h2>
  <p>Read the changelog before upgrading.</p>
</aside>

セレクターのグループ化とカスタムプロパティだけでも、枠のルールをまとめられます。次は既存の CSS を使った例です。

:root {
  --panel-radius: 0.5rem;
}
 
.site-card,
.site-notice {
  border: 1px solid var(--panel-accent, #777777);
  border-radius: var(--panel-radius);
  padding: var(--panel-space, 1.5rem);
}
 
.site-card > .site-panel-title,
.site-notice > .site-panel-title {
  margin-block: 0 0.75rem;
  color: var(--panel-accent, #777777);
}
 
.site-notice {
  --panel-accent: #45687d;
  --panel-space: 1rem;
}

ここには二種類の再利用があります。グループ化したセレクターは異なる要素で宣言を共有し、カスタムプロパティは置き換えられる値を提供します。var() はプロパティ値の中で変数を置換するものであり、それ自体が border、padding、入れ子のルールのまとまりを別のルールへ挿入するわけではありません。この置換の仕組みはカスタムプロパティの仕様で定義されています。

HTML を変更できるなら、共通ルールを .site-panel に置き、class="site-panel site-card" と class="site-panel site-notice" を使う方法もあります。共通 class が枠を表し、もう一つの class がコンポーネント自身の役割を表します。色と内側の余白は引き続きカスタムプロパティで制御します。

二種類のコンテナしかないこの小さなサイトでは、私はまずこの方法を選びます。宣言がまとまり、違いが見え、追加の呼び出し関係を追う必要もありません。後から三種類目が増えても、本当に同じ枠を共有するなら、同じ class を加えれば明快です。

共通ルールを複数のコンポーネントのスタイルで組み合わせるとき、各所で異なる設定を明示的に渡したいとき、あるいは出所の異なる HTML に共通 class を加えにくいとき、mixin は別の整理方法を提供します。適用箇所を CSS に残し、どのルールを選んだかを名前で示せます。

再利用したいものまず検討できる方法保守する関係
色、余白、角丸などの値カスタムプロパティ変数の出所、継承、上書き
完全に同じ宣言のまとまり共通 class、セレクターのグループ化どの要素が一緒に変更を受けるべきか
引数を持ち、別のルールで組み合わせられるスタイルブロックネイティブ mixin の草案呼び出し箇所、引数、ブロックの責任

三つは併用できます。mixin の中でデザイントークンを参照してもよいですし、共通 class を mixin の適用先にもできます。選択の目的は、変更の責任を明確にすることです。

@mixin で定義し、@apply の位置で適用する

枠を mixin にすると、最小の形は次のようになります。この例と後続の mixin の例は、すべて草案の構文です。

/* Draft syntax: definition and application */
@mixin --panel-frame() {
  border: 1px solid #777777;
  border-radius: 0.5rem;
  padding: 1.5rem;
}
 
.site-card {
  @apply --panel-frame();
}
 
.site-notice {
  @apply --panel-frame();
}

@mixin は再利用可能なブロックを定義し、名前は -- で始まります。@apply は適用する位置を指定します。草案の定義と適用のルールでは、mixin の定義を通常のスタイルルールの中には置けません。一方、@apply はスタイルルールやその中の入れ子のグループルールで使います。そのため、上の例では定義をトップレベルに、呼び出しを各セレクター内に置いています。

まずは、呼び出し位置に宣言とルールのまとまりを導入する、と捉えられます。ただし、これは読むためのモデルにすぎません。引数、変数、スコープが関わるとき、任意の文字列のコピーと貼り付けだと考えてはいけません。

引数がない場合、現在の草案では @apply --panel-frame; のように括弧を省略できます。この記事では呼び出しとわかりやすくし、後で引数を追加するときにも一貫するよう、括弧を残します。@apply に class 名を渡しているのではなく、定義済みの mixin 名を参照しています。

次に、アクセントカラーと内側の余白を調整可能にします。以下は前の定義を置き換える完全な CSS で、先ほどの HTML と組み合わせます。

/* Draft syntax: parameters, defaults, and nested rules */
@mixin --panel-frame(
  --accent <color>: #777777,
  --space <length>: 1.5rem
) {
  border: 1px solid var(--accent);
  border-radius: 0.5rem;
  padding: var(--space);
 
  & > .site-panel-title {
    margin-block: 0 0.75rem;
    color: var(--accent);
  }
}
 
.site-card {
  @apply --panel-frame();
}
 
.site-notice {
  @apply --panel-frame(#45687d, 1rem);
}

--accent は色、--space は長さを受け取り、コロンの後ろが引数を省略した場合のデフォルト値です。カードはデフォルトを使い、通知は青みがかったグレーと狭めの余白を渡します。型とデフォルト値の書き方は草案の引数構文に従います。例えば 1.5rem は <length> に適合しますが、パーセントはこの型には含まれません。

引数を二つに絞ったのは、先に確認した違いに対応させるためです。角丸は共通のルールに残し、見出しも枠のアクセントカラーに従います。呼び出し箇所を読めば、そのコンポーネントがどの変化を選んだかがわかります。

引数は var() で読み取りますが、要素に通常の公開カスタムプロパティを追加することと同じではありません。現在の引数モデルでは、同名の引数が外側の値を隠し、プライベートなスコープが値を取得できる要素を制限します。上の例はコンテナとその子要素だけを扱います。兄弟要素を選択するように変えた場合も引数が使えるとは、そのまま仮定できません。

入れ子の & > .site-panel-title は、「枠と見出しがアクセントカラーを共有する」ことを一つのルールにします。同時に、見出しがコンテナの直接の子要素であるという構造上の約束も生まれます。その構造を持たないコンポーネントがあるなら、あらゆる状況を包み込むようにセレクターを増やすより、見出しと枠を分けるべきです。

@contents で条件を共有し、内容は呼び出し側に残す

新しい要件を加えます。画面が狭くなったとき、カードも通知も調整しますが、調整内容は異なります。カードは配置を詰め、通知は内側の余白を減らして側面の枠線を強調します。

ページ全体で共通の狭いレイアウトの条件なら、その条件に名前を付け、@contents でスタイルを入れる場所を用意できます。次の草案例は、前節の CSS に続けて置きます。

/* Draft syntax: shared condition, caller-supplied content */
@mixin --compact-layout() {
  @media (width <= 40rem) {
    @contents;
  }
}
 
.site-card {
  @apply --compact-layout() {
    display: grid;
    gap: 0.75rem;
    padding: 1rem;
  }
}
 
.site-notice {
  @apply --compact-layout() {
    padding: 0.75rem;
    border-inline-start-width: 3px;
  }
}

@apply の後ろのブロックが呼び出し側から渡す内容で、@contents がその内容を入れる位置です。草案の @contents の定義では、mixin がこうしたブロックを受け取れます。共有するのは「いつ狭いレイアウトにするか」であり、それぞれをどう変えるかはカードと通知のスタイルに残ります。

40rem はこの架空の例で選んだレイアウト条件で、汎用的なデバイス分類ではありません。カードと通知が別々のコンテナの利用可能な幅に応じて変わるべきなら、同じビューポートのしきい値を無理に共有しても意味がなくなります。そのときは、mixin があるから使い続けるのではなく、条件を見直すべきです。

@contents にはデフォルトのブロックも指定できます。呼び出し側が内容をまったく渡さない場合にだけ使われます。空のブロックを渡すことは、空ではあっても内容を渡すことです。この記事の --compact-layout() はデフォルト内容を持たないため、ブロックなしで呼び出すだけでは宣言は追加されません。

今の二つのコンポーネントなら、一つの @media 内に二つのルールを直接書いても十分わかりやすいでしょう。条件 mixin の価値は、複数の場所で本当に同じレイアウト判断を共有し、かつスタイルをそれぞれのコンポーネントの近くに残したいときに生まれます。名前が増えるなら、そのぶん意図が明確になるべきです。

抽象化のコストは、変更する日に現れる

今度は販促カード、エラー通知、サイドバーの概要が増えたとします。--panel-frame() に枠線の太さ、背景、アイコン位置、見出しの大きさを加え、特殊な場合を制御するスイッチも足したくなるかもしれません。

引数が増えてきたら、それらの関係を見直す必要があります。大半の呼び出しが設定の半分を上書きしていたり、ある一つのコンポーネントだけが特殊な引数群を使っていたりするなら、元の「共通の枠」は複数の要件の寄せ集めになっているかもしれません。ルールを小さく分けたり、一部のコンポーネントには独自の宣言を残したりするほうが保守しやすい場合があります。

私は三つの問いで、この境界を確認します。

  • 変更は一緒に起きるか。 角丸の調整はすべてのコンテナに影響すべきか、それとも特定の内容だけか。
  • 呼び出し箇所から意図がわかるか。 色と余白なら理解しやすくても、長い位置引数の列では定義を何度も見返す必要があります。
  • 例外はどこに置くと明快か。 通知だけの側面の枠線は、サイト全体の共通オプションにせず、通知のルールに残せます。

mixin に切り出すと、保守する人は少なくとも呼び出し箇所、定義、渡された値を結びつけて読む必要があります。mixin 内で別の mixin を呼べば、追う経路はさらに長くなります。変更を一か所に集める便利さは、この読むコストと一緒に考える必要があります。

混ぜ込まれた宣言も、ほかのスタイルとともに動きます。mixin を適用しても、元のカスケードの問題が消えるわけではありません。この単純な例なら、@apply の後ろにコンポーネント独自の角丸を宣言して局所的な違いを表せます。しかし別の出所から同じプロパティが設定されたら、出所、レイヤー、詳細度、順序を引き続き確認しなければなりません。抽象化で、実際に効いている値の説明を難しくするべきではありません。

さらに重要なのは、class も mixin も一緒に変更する関係を作ることです。今日似ている二つの場所が、今後も常に同期すべきとは限りません。まずルールに妥当な名前を付け、それから実装手段を選ぶほうが、行数の最小化を先に求めるより確かです。

今、学ぶ価値があるのはどの部分か

Sass に慣れていれば、この概念はなじみ深いでしょう。ただし Sass の mixin は @include で適用し、引数を $ で命名し、Sass の処理後に CSS を出力します。この記事のネイティブ提案は @apply、-- の名前、CSS の値の仕組みを使います。用途が似ていても、構文、スコープ、移行方法まで同じとは推論できません。

同じ CSS 草案の @function は値を扱います。例えば入力から長さを計算して padding などのプロパティに使います。@mixin は複数の宣言や入れ子のルールを含められるスタイルブロックを扱います。再利用したいものがどの層にあるかで、選ぶ能力は変わります。

草案自体も整理の途中です。この記事の例は 5.1〜5.4 節の定義に基づいています。5.5 節 には新しいプライベートプロパティモデルに合わせて更新する必要があるという注記が残り、古い @result の記法も残っています。そのため、内部の置換手順を確定済みの挙動として扱わず、構文の説明を互換性の保証ともしません。

最初のドキュメントサイトに戻ると、私はやはり共通 class やセレクターのグループ化から始め、明確な値の違いをカスタムプロパティに任せます。ルールを本当にコンポーネント間で組み合わせる必要が生まれ、明確な引数のインターフェースが必要になってから、mixin の表現に価値があるかを検討します。

ネイティブ @mixin の提案は、CSS の再利用を単一の値から、名前を持ち調整できるルールのまとまりへ広げる方向を示しています。その能力を役立てるためにも、最初の問いに答える必要があります。次に変更するとき、どのスタイルが一緒に変わるべきなのでしょうか。