CSS @scope at-rule でセレクタのリーチを制限する

@scope を使用して、DOM の限定されたサブツリー内の要素のみを選択する方法について説明します。

公開日: 2023 年 10 月 4 日

Browser Support

  • Chrome: 118.
  • Edge: 118.
  • Firefox: 146.
  • Safari: 17.4.

Source

セレクタを記述する際に、2 つの世界の間で迷うことがあるかもしれません。一方では、どの要素を選択するかをかなり具体的に指定する必要があります。一方、セレクタは簡単にオーバーライドでき、DOM 構造に密結合しないようにする必要があります。

たとえば、「カード コンポーネントのコンテンツ領域のヒーロー画像」というかなり具体的な要素を選択したい場合、.card > .content > img.hero のようなセレクタを記述したくないでしょう。

  • このセレクタの特異性(0,3,1) とかなり高いため、コードが大きくなるとオーバーライドが難しくなります。
  • 直接の子孫セレクタに依存しているため、DOM 構造に密結合しています。マークアップが変更された場合は、CSS も変更する必要があります。

ただし、その要素のセレクタとして img とだけ記述すると、ページ内のすべての画像要素が選択されてしまうため、それも避けたいところです。

この適切なバランスを見つけるのは、多くの場合、非常に難しいことです。長年にわたり、一部のデベロッパーは、このような状況で役立つソリューションや回避策を考案してきました。次に例を示します。

  • BEM などの方法論では、セレクタの特異性を低く保ちながら、選択するものを具体的に指定できるように、その要素に card__img card__img--hero というクラスを付与することが推奨されています。
  • Scoped CSSStyled Components などの JavaScript ベースのソリューションでは、セレクタにランダムに生成された文字列(sc-596d7e0e-4 など)を追加して、セレクタがページの反対側の要素をターゲットにしないようにすることで、すべてのセレクタを書き換えます。
  • 一部のライブラリでは、セレクタが完全に廃止され、スタイリング トリガーをマークアップ自体に直接配置する必要があります。

しかし、これらのいずれも必要ない場合はどうすればよいでしょうか。CSS で、選択する要素をかなり具体的に指定しながら、特異性の高いセレクタや DOM に密接に結合されたセレクタを記述する必要がない方法があったらどうでしょうか。そこで @scope が登場します。これを使用すると、DOM のサブツリー内の要素のみを選択できます。

@scope の導入

@scope を使用すると、セレクタの範囲を制限できます。これを行うには、ターゲットとするサブツリーの上限を決定するスコーピング ルートを設定します。スコーピング ルートを設定すると、含まれるスタイルルール(スコーピング スタイルルール)は、DOM のその限定されたサブツリーからのみ選択できます。

たとえば、.card コンポーネントの <img> 要素のみをターゲットにするには、@scope @規則のスコープ ルートとして .card を設定します。

@scope (.card) {
    img {
        border-color: green;
    }
}

スコープ付きスタイルルール img { … } は、一致する .card 要素のスコープ内にある <img> 要素のみを効果的に選択できます。

カードのコンテンツ領域(.card__content)内の <img> 要素が選択されないようにするには、img セレクタをより具体的にします。もう 1 つの方法は、@scope at-rule が下限を決定するスコープ制限も受け入れるという事実を利用することです。

@scope (.card) to (.card__content) {
    img {
        border-color: green;
    }
}

このスコープ付きスタイルルールは、祖先ツリーの .card 要素と .card__content 要素の間に配置された <img> 要素のみを対象とします。上限と下限のあるこのタイプのスコープは、ドーナツ スコープと呼ばれることがよくあります。

:scope セレクタ

デフォルトでは、すべてのスコープ付きスタイルルールはスコープ ルートを基準としています。スコーピング ルート要素自体をターゲットにすることもできます。これには、:scope セレクタを使用します。

@scope (.card) {
    :scope {
        /* Selects the matched .card itself */
    }
    img {
       /* Selects img elements that are a child of .card */
    }
}

スコープ付きスタイルルールのセレクタには、暗黙的に :scope が付加されます。必要に応じて、:scope を自分で付加することで、明示的に指定することもできます。または、CSS ネスト& セレクタを先頭に追加することもできます。

@scope (.card) {
    img {
       /* Selects img elements that are a child of .card */
    }
    :scope img {
        /* Also selects img elements that are a child of .card */
    }
    & img {
        /* Also selects img elements that are a child of .card */
    }
}

スコーピング制限では、:scope 疑似クラスを使用して、スコーピング ルートとの特定の関係を要求できます。

/* .content is only a limit when it is a direct child of the :scope */
@scope (.media-object) to (:scope > .content) { ... }

スコーピング制限では、:scope を使用して、スコーピング ルートの外部にある要素を参照することもできます。次に例を示します。

/* .content is only a limit when the :scope is inside .sidebar */
@scope (.media-object) to (.sidebar :scope .content) { ... }

スコープ付きスタイルルール自体はサブツリーから抜け出すことはできません。:scope + p のような選択は、スコープ外の要素を選択しようとするため無効です。

@scope と特異性

@scope の前置きで使用するセレクタは、含まれるセレクタの特異性に影響しません。この例では、img セレクタの特異度は (0,0,1) のままです。

@scope (#sidebar) {
  img { /* Specificity = (0,0,1) */
    ...
  }
}

:scope の特異性は、通常の疑似クラス((0,1,0))の特異性と同じです。

@scope (#sidebar) {
  :scope img { /* Specificity = (0,1,0) + (0,0,1) = (0,1,1) */
    ...
  }
}

次の例では、内部的に & がスコープ設定ルートに使用されるセレクタに書き換えられ、:is() セレクタでラップされます。最終的に、ブラウザは :is(#sidebar, .card) img をセレクタとして使用して照合を行います。このプロセスは「脱糖」と呼ばれます。

@scope (#sidebar, .card) {
    & img { /* desugars to `:is(#sidebar, .card) img` */
        ...
    }
}

&:is() を使用して脱糖されるため、& の特異性は :is() の特異性ルールに従って計算されます。つまり、& の特異性は、最も特異性の高い引数の特異性になります。

この例に適用すると、:is(#sidebar, .card) の特異性は、最も特異性の高い引数である #sidebar の特異性となり、(1,0,0) になります。これに img の特異性((0,0,1))を組み合わせると、複雑なセレクタ全体の特異性は (1,0,1) になります。

@scope (#sidebar, .card) {
  & img { /* Specificity = (1,0,0) + (0,0,1) = (1,0,1) */
    ...
  }
}

@scope 内の :scope& の違い

特異度の計算方法の違いに加えて、:scope は一致したスコープ ルートを表し、& はスコープ ルートの一致に使用されたセレクタを表すという違いもあります。:scope&

そのため、& を複数回使用できます。これは、スコーピング ルート内でスコーピング ルートを照合できないため、1 回しか使用できない :scope とは対照的です。

@scope (.card) {
  & & { /* Selects a `.card` in the matched root .card */
  }
  :scope :scope { /* ❌ Does not work */
    
  }
}

Prelude なしのスコープ

<style> 要素でインライン スタイルを記述する場合、スコープ ルートを指定しないことで、スタイルルールを <style> 要素を囲む親要素にスコープできます。これを行うには、@scope のプレリュードを省略します。

<div class="card">
  <div class="card__header">
    <style>
      @scope {
        img {
          border-color: green;
        }
      }
    </style>
    <h1>Card Title</h1>
    <img src="…" height="32" class="hero">
  </div>
  <div class="card__content">
    <p><img src="…" height="32"></p>
  </div>
</div>

上記の例では、div<style> 要素の親要素であるため、スコープ設定されたルールはクラス名 card__headerdiv 内の要素のみを対象とします。

カスケードの @scope

CSS カスケード内で、@scope は新しい基準(スコープの近接性)も追加します。ステップは、特異性の後、出現順序の前に来ます。

CSS カスケードの可視化。

仕様どおり:

スコープ ルートが異なるスタイルルールに現れる宣言を比較する場合、スコープ ルートとスコープ付きスタイルルール サブジェクトの間の世代要素または兄弟要素のホップ数が最も少ない宣言が優先されます。

この新しいステップは、コンポーネントの複数のバリエーションをネストする場合に便利です。まだ @scope を使用していない次の例を見てみましょう。

<style>
    .light { background: #ccc; }
    .dark  { background: #333; }
    .light a { color: black; }
    .dark a { color: white; }
</style>
<div class="light">
    <p><a href="#">What color am I?</a></p>
    <div class="dark">
        <p><a href="#">What about me?</a></p>
        <div class="light">
            <p><a href="#">Am I the same as the first?</a></p>
        </div>
    </div>
</div>

このマークアップを少し見てみると、3 番目のリンクは div の子で、クラス .light が適用されているにもかかわらず、black ではなく white になります。これは、カスケードが勝者を決定するために使用する表示順序の条件によるものです。.dark a が最後に宣言されたため、.light a ルールが優先されます。

スコープ設定の近接性条件を使用すると、この問題は解決します。

@scope (.light) {
    :scope { background: #ccc; }
    a { color: black;}
}

@scope (.dark) {
    :scope { background: #333; }
    a { color: white; }
}

スコープ付き a セレクタの特異性が同じであるため、スコープの近接性基準が適用されます。両方のセレクタの重みは、スコープ ルートからの距離によって決まります。3 番目の a 要素の場合、.light スコーピング ルートまでは 1 ホップですが、.dark までは 2 ホップです。したがって、.lighta セレクタが優先されます。

セレクタの分離(スタイルの分離ではない)

@scope はセレクタの範囲を制限します。スタイル分離は提供されません。子に継承されるプロパティは、@scope の下限を超えても継承されます。color プロパティはその一例です。ドーナツ スコープ内で宣言した場合、color はドーナツの穴の中の子に継承されます。

@scope (.card) to (.card__content) {
  :scope {
    color: hotpink;
  }
}

この例では、.card__content 要素とその子要素は .card から値を継承するため、hotpink 色になります。