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

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

対応ブラウザ

  • 118
  • 118
  • x
  • x

CSS セレクタの繊細な記述

セレクタを記述する際、2 つの世界に行き詰まることがあります。一方で、選択する要素を細かく指定することをおすすめします。一方、セレクタは簡単にオーバーライドでき、DOM 構造には密接に結び付かないようにする必要があります。

たとえば、「カード コンポーネントのコンテンツ エリアのヒーロー画像」を選択する場合(これはかなり特殊な要素の選択です)、.card > .content > img.hero のようなセレクタを記述することはおすすめしません。

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

しかし、その要素のセレクタとして img のみを記述することはおすすめしません。ページ内のすべての画像要素が選択されるためです。

この中で適切なバランスを見出すことは、多くの場合、困難な作業です。何年にもわたって、一部のデベロッパーは、このような状況で助けとなる解決策や回避策を見つけてきました。例:

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

では、それらが不要な場合はどうすればよいでしょうか。CSS によって、選択対象の要素を細かく指定でき、特異性の高いセレクタや、DOM に密接に結びついたセレクタを記述する必要がないとしたらどうでしょうか。そこで役立つのが @scope です。これにより、DOM のサブツリー内の要素のみを選択できるようになります。

@scope のご紹介

@scope を使用すると、セレクタのリーチを制限できます。そのためには、ターゲットとするサブツリーの上限を決定するスコープ ルートを設定します。スコープのルートセットを使用すると、含まれるスタイルルール(スコープのスタイル ルール)は、DOM の限られたサブツリーからのみ選択できるようになります。

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

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

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

カードのコンテンツ領域(.card__content)内にある <img> 要素が選択されないようにするには、img セレクタをより限定します。別の方法として、@scope at ルールでも、下限を決定するスコープ制限を受け入れます。

@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 Nesting& セレクタを先頭に追加することもできます。

@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 */
  }
  :root :root { /* ❌ Does not work */
    …
  }
}

プレリュードなしのスコープ

<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 番目のリンクは、クラス .light が適用された div の子であっても、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 色になります。

(カバー写真: rustam burkhanovUnsplash