乗りこなせ! モダンフロントエンド

Scoped Styles/スコープ付きスタイルルール
[CSS Modern Features no.5]

Scoped Styles/スコープ付きスタイルルール

こんにちは!サイボウズ フロントエンドエキスパートチームの麦島です。

今回取り上げるCSSの機能はScoped Stylesです。

Scoped Stylesは@scope At-Rulesを用いて、CSSの適用範囲を従来よりも細かく絞り込んで制御するための機能です。CSSではカスケードの都合上、子孫要素へのCSSプロパティの継承の制御は難しいものでしたが、@scopeを用いることでより厳密な制御が可能となります。

コードは次のような形です。

/* 
  @scope の利用例
*/
@scope (.parent) {
  /* .parentクラスを持つ要素の文字を赤色とする */
  :scope {
    color: red;
  }
  /* .parentクラスを持つ要素の配下の.childクラスを持つ要素の文字は青色とする */
  .child {
    color: blue;
  }
}

実際にHTMLを用意して適用してみると、次のような見た目となります。

<div class="parent">
  <div>Parent Text</div>
  <div class="child">Child Text</div>
</div>
<div class="other-parent">
  <div>Other Parent Text</div>
  <div class="child">Other Child Text</div>
</div>
図1 @scopeによる簡易サンプルの表示例

.childクラスが付与されている要素は2つ存在していますが、そのうち.parentクラスが付与されている要素の配下のもののみ文字色が青色になっており、適用箇所が限定的であることがわかります。

しかしこれだけでは、CSS Nestingなどで適用範囲を絞っても同じことができますし、Scoped Stylesの利点があまり見えてこないかもしれません。Scoped Stylesがどういったシチュエーションで活用できるかを正しく理解するため、事例を見ながら確認していきましょう。

Scoped Stylesの利用例/ネストした要素での限定的なスタイルの適用と課題

まずはCSSで陥りがちな問題を見てみましょう。次のような大小2種類のカードの表示を例に考えてみます。

<!-- 大きいカード -->
<section class="card-large">
  <h3>Large Card</h3>
  <div>Large Card Content</div>

  <!-- 大きいカード内での小さいカード -->
  <section class="card-small">
    <h3>Small Card (Nested)</h3>
    <div>Small Card Content (Nested)</div>
  </section>
</section>

<!-- 小さいカード -->
<section class="card-small">
  <h3>Small Card</h3>
  <div>Small Card Content</div>
</section>

大小のカードが並んで表示されており、かつ大きいカードの中にも小さいカードが含まれています。また、次のようにカードの種類に応じてフォントサイズを切り替えています。

/* 小さいカード向けのスタイル */
.card-small {
  font-size: 12px;
}
/* 大きいカード向けのスタイル */
.card-large {
  font-size: 24px;
}

この段階での画面上での見た目は次のようなイメージです。

図2 カードコンテンツの初期表示

このスタイルに対して、大きいカードに含まれるタイトルh3要素)の文字サイズを変更するケースを想定してみましょう。大きいカードには.card-largeが付与されているため、単純に次のように.card-large配下のh3要素に対してスタイルを適用すれば文字サイズを変更できそうです。

/* 小さいカード向けのスタイル */
.card-small {
  font-size: 12px;
}
/* 大きいカード向けのスタイル */
.card-large {
  font-size: 24px;
}
.card-large h3 {
  /* 大きいカードのタイトル文字サイズをさらに大きく */
  font-size: 40px;
}

しかし、この場合ではネストされている小さいカードのタイトルについても大きい文字サイズで表示されてしまいます。

図3 ネストした小さいカードにも意図せずスタイルが適用された状態

新たに追加したCSSは.card-large h3であり、.card-large配下のすべてのh3要素が対象となっています。そのため、ネストしている小さいカードに含まれるh3も該当してしまうのが原因です。

では、次のように小さいカードのタイトルに対して文字サイズのリセット指定を行えば回避できるでしょうか?

/* 小さいカード向けのスタイル */
.card-small {
  font-size: 12px;
}
/* 大きいカード向けのスタイル */
.card-large {
  font-size: 24px;
}
.card-small h3 {
  /* 小さいカードのタイトル文字サイズはブラウザ標準の値に戻す */
  font-size: revert;
}
.card-large h3 {
  /* 大きいカードのタイトル文字サイズをさらに大きく */
  font-size: 40px;
}

しかし、これは期待通りの表示とならず、小さいカードのタイトルの文字サイズは大きいままとなります。.card-small h3.card-large h3ではCSS詳細度が同じであり、CSSカスケードにおいて、より後に宣言されたスタイルが適用されます。そのため、.card-large h3のスタイルが適用され、新たに追加した.card-small h3のスタイルは無視されてしまいます。

「では定義順を入れ替えれば良いのでは?」と考えるかもしれませんが、今度は逆に小さいカードの中に大きいカードをネストしたいケースが発生すると同様の事象が再発し破綻するため、根本的な解決とはなりません。

解決策のひとつとして、CSSセレクタを修正し、スタイルの適用範囲を限定的とする方法が考えられます。

/* 小さいカード向けのスタイル */
.card-small {
  font-size: 12px;
}
/* 大きいカード向けのスタイル */
.card-large {
  font-size: 24px;
}
.card-large > h3 {
  /* 大きいカード直下のタイトル文字サイズを大きく */
  font-size: 40px;
}

これによって、ひとまずは狙い通り大きいカードのタイトルのみが大きく表示されます。

図4 ネストした小さいカードにも意図せずスタイルが適用された状態

しかし、この方法の場合だとマークアップ構造に強く依存します。仮にh3タグを何らかのタグで囲むといった変更があると.card-large > h3のセレクタは適用されなくなるため、新しいマークアップ構造に併せて修正が必要です。

このように、従来のCSSの機能では、詳細度が同じCSSについては、適用順序を定義順以外でコントロールするのは困難です。さらに、定義順を工夫しても祖先や子孫要素と干渉しないように適用範囲を限定することはできません。これらを解決するにはマークアップ構造に依存した詳細度を高めた記述をするなど、保守性とトレードオフになっていました。

Scoped Styles@scopeを用いた場合

では、@scopeを用いた場合の例を見てみましょう。同じスタイルを@scopeを用いて表現した場合、次のようなCSSとなります。

/* 小さいカード向けのスタイル */
@scope (.card-small) {
  :scope {
    font-size: 12px;
  }
  h3 {
    font-size: revert;
  }
}

/* 大きいカード向けのスタイル */
@scope (.card-large) {
  :scope {
    font-size: 24px;
  }
  h3 {
    font-size: 40px;
  }
}

@scopeを用いて.card-smallおよび.card-largeクラスが付与された要素の配下を対象にスコープを作成しています。:scope擬似クラスは、@scopeで指定したスコープのルート要素自体を指します。

h3要素へのスタイル指定において、.card-smallおよび.card-largeの直下である指定がなく、マークアップ構造への依存が抑えられていることがわかります。また、大きいカードの中に小さいカードをネストしても、.card-smallのスコープに定義されたスタイルが尊重されて期待通りの表示結果となり、Scoped Stylesを用いない場合に生じていた問題がすべて解消されます。

なお、詳細度の計算については@scopeそのものに指定したセレクタは無視されます。例の場合、@scope (.card-large).card-largeのクラス指定の詳細度は無視されるため、スコープ内のh3要素に対するスタイル指定の詳細度は0-0-1となります。

スコープの近接性(ホップ数)による優先順位

前述の@scopeの例において、次のように@scope (.card-small)@scope (.card-large)の定義順序を入れ替えたとしても表示に変化はありません。

/* 大きいカード向けのスタイル */
@scope (.card-large) {
  :scope {
    font-size: 24px;
  }
  h3 {
    font-size: 40px;
  }
}

/* 小さいカード向けのスタイル */
@scope (.card-small) {
  :scope {
    font-size: 12px;
  }
  h3 {
    font-size: revert;
  }
}

しかし、実際のマークアップ構造上では大きいカード内に小さいカードがネストして存在しており、大きいカード向けのスタイルである@scope (.card-large)に含まれるh3要素は、.card-small配下のh3も該当します。なぜ確実に小さいカード向けのスタイルが尊重されて機能するのでしょうか?

これには@scopeが持つスコープの近接性が影響しています。複数のスコープで詳細度が同じ場合、マークアップ上でスコープのルート要素への階層がより近いものが優先されます。この階層の距離をホップ数と呼びます。

例で考えてみると、ネストしている.card-small配下のh3から見たときには、@scope (.card-large)@scope (.card-small)の2つのスコープに該当します。このとき、@scope (.card-large)のスコープに関しては、h3要素から.card-largeまでのホップ数は2となります。

図5 h3要素から@scope (.card-large)のスコープルートまでのホップ数

一方、@scope (.card-small)のスコープで考えた場合には、スコープルートに対してのホップ数は1となります。

図6 h3要素から@scope (.card-small)のスコープルートまでのホップ数

結果として、ネストしている.card-small配下のh3から見た場合には、@scope (.card-small)の指定のほうがホップ数が小さく、より近接性が高いと判断されるため、優先して適用されます。このルールにより、詳細度が同じ場合でも@scopeによって意図した通りに適用順序をコントロールできます。

scoping limitによる適用の下限範囲指定

@scopeルールにはscoping limitと呼ばれる機能があり、スコープ適用範囲の下限=スコープリミットを指定できます。scoping limitはtoで指定します。先ほどまでの大小のカードのスタイリングの例をscoping limitを用いると次のように表現できます。

/* 小さいカード向けのスタイル */
@scope (.card-small) to (.card-large, .card-small) {
  :scope {
    font-size: 12px;
  }

  /* このスタイルは不要となる */
  /*
  h3 {
    font-size: revert;
  }
  */
}

/* 大きいカード向けのスタイル */
@scope (.card-large) to (.card-large, .card-small) {
  :scope {
    font-size: 24px;
  }
  h3 {
    font-size: 40px;
  }
}

@scopeの宣言時に、新たにto (.card-large, .card-small)を付与しています。この場合、スコープ内の.card-largeクラスまたは.card-smallクラスを保持している要素はスコープリミットとなり、@scopeの適用範囲からは除外されます。

図7 @scope (.card-large)のスコープリミットの適用範囲

ポイントは@scope (.card-small)の中のh3要素に対するスタイル指定が不要となっている点です。scoping limitによって@scope (.card-large)のスコープがネストした.card-small配下には適用されないため、revertでスタイルをリセットする必要がありません。

データ属性と組み合わせたコンポーネントでの@scopeの活用

コンポーネント指向でUIを開発している場合、各コンポーネントのルート要素に対して共通のデータ属性を付与してscoping limitの境界とすることで@scopeを扱いやすくできます。この方法は、W3CのEditor's Draft上でもとして紹介されています。

さて、ここまで紹介したカード表示のサンプルを、Reactと@scopeを組み合わせたコンポーネントとして実装した例を見てみましょう。

import "./Card.css";

export function Card({ title, size, children }) {
  const scope = size === "small" ? "card-small" : "card-large";

  return (
    <section data-scope={scope}>
      <h3>{title}</h3>
      <div>{title} Content</div>
      {children}
    </section>
  );
}
/* Card.css の一部を抜粋 */

@scope ([data-scope="card-small"]) to ([data-scope]) {
  :scope {
    font-size: 12px;
  }
}

@scope ([data-scope="card-large"]) to ([data-scope]) {
  :scope {
    font-size: 24px;
  }
  h3 {
    font-size: 40px;
  }
}
// コンポーネントの利用例
<Card title="Large Card" size="large">
  <Card title="Small Card" size="small" />
</Card>
<Card title="Small Card" size="small" />

コンポーネントのルート要素にdata-scope属性を付与してスタイルを適用していますが、scoping limitによってネストしたdata-scope属性配下にはスタイルが適用されません。これによって、コンポーネントがネストしても子孫コンポーネントのスタイルに影響を与えることはありません。仮にカード以外のコンポーネントと相互にネストしても、各コンポーネントが同様のルールでdata-scope属性をルートに持ち@scopeでスタイリングしていれば、スタイルが干渉することはありません。このように、一定のルールは必要となりますが、コンポーネント指向での開発と@scopeを組み合わせることで、影響範囲の閉じたスタイリングを実現できます。

詳細度とScoped Styles

Scoped Stylesによって、特定の範囲にのみスタイルを適用できることを確認できました。しかし現実のユースケースを考えると、Scoped Stylesを用いないスタイルと併用するケースも多いでしょう。では、Scoped Stylesでのスタイルとそれ以外のスタイルで詳細度が異なる場合、どちらが優先されるのでしょうか?

Scoped Stylesを含むCSSの適用順序は、W3CのCSS Cascading and Inheritance Level 6のCascade Sorting Orderに定義されています。そのうち一部を抜粋すると、次の順序となる旨の記述があります。

  1. Specificity(詳細度)
  2. Scope Proximity(スコープの近接性)

注目すべきは、まず先に詳細度が優先され、その後にスコープの近接性が考慮される点です。つまり、詳細度で下回る場合にはスコープの有無は一切考慮されません。

次のようなHTMLとCSSで確認してみましょう。

<main>
  <section>
    <h2>Content A</h2>
    <h2 class="title">Content B</h2>
  </section>
</main>
/** 詳細度 0-0-2 */
section h2 {
  color: blue;
}

@scope (section) {
  /** 詳細度 0-0-1 */
  h2 {
    color: red;
  }

  /** 詳細度 0-1-0 */
  .title {
    color: green;
  }
}

h2要素が2つ存在しており、片方にはclass属性が付与されています。CSSでは、@scopeを用いてそれぞれのh2要素に対してスタイルを適用していますが、詳細度に差がある状態となっており、.titleへのclass属性での指定のみが、@scope外に定義されているsection h2よりも詳細度で上回っています。このとき、見た目は次のようになります。

図8 @scope内外で異なる詳細度のスタイルを適用した場合の見た目

@scopeの有無にかかわらず、詳細度の順にスタイルが適用されていることがわかります。理解としては「適用順序の判断においてScoped Stylesが考慮されるのは詳細度が一致する場合のみである」と考えても差し支えないでしょう。Scoped Stylesを誤って「特定範囲に確実にスタイルを適用するもの」と認識すると勘違いしやすい部分のため、注意が必要です。

余談ですが、Scoped Stylesと詳細度のどちらを優先して判断するかについては、仕様検討時にも長らく議論されていた点となります。興味があれば該当Issueにも目を通してみると理解が深まるかもしれません。

ブラウザでのサポート状況

今回紹介した機能について、本記事掲載時点でのメジャーブラウザにおける利用可能バージョンは次のとおりです。これ以降のバージョンであれば利用可能です。

機能 Chrome Edge Safari Firefox
Scoped Styles (@scope) 118 118 17.4 (未対応)

なお、今回紹介したScoped Stylesは、本記事掲載時点では仕様的にはWorking Draftであり、今後仕様が変更される可能性がある点についてはご留意ください。

まとめ

影響範囲を閉じたスタイル適用は、従来ではBEMなどの設計手法に頼ったり、CSS ModulesやVue.jsのScoped CSSといったライブラリやフレームワークが用いられてきました。同様の機能が@scopeでのScoped Stylesで実現可能となります。独自の設計ルールの策定をなくせたり、依存パッケージを減らせるといったメリットもあるため、コンポーネントのスタイリング時の選択肢の一つとして覚えておくとよいでしょう。

おすすめ記事

記事・ニュース一覧