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>
.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;
}
この段階での画面上での見た目は次のようなイメージです。
このスタイルに対して、大きいカードに含まれるタイトルh3
要素).card-large
が付与されているため、単純に次のように.card-large
配下のh3
要素に対してスタイルを適用すれば文字サイズを変更できそうです。
/* 小さいカード向けのスタイル */
.card-small {
font-size: 12px;
}
/* 大きいカード向けのスタイル */
.card-large {
font-size: 24px;
}
.card-large h3 {
/* 大きいカードのタイトル文字サイズをさらに大きく */
font-size: 40px;
}
しかし、この場合ではネストされている小さいカードのタイトルについても大きい文字サイズで表示されてしまいます。
新たに追加した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;
}
これによって、ひとまずは狙い通り大きいカードのタイトルのみが大きく表示されます。
しかし、この方法の場合だとマークアップ構造に強く依存します。仮にh3
タグを何らかのタグで囲むといった変更があると.card-large > h3
のセレクタは適用されなくなるため、新しいマークアップ構造に併せて修正が必要です。
このように、従来のCSSの機能では、詳細度が同じCSSについては、適用順序を定義順以外でコントロールするのは困難です。さらに、定義順を工夫しても祖先や子孫要素と干渉しないように適用範囲を限定することはできません。これらを解決するにはマークアップ構造に依存した詳細度を高めた記述をするなど、保守性とトレードオフになっていました。
Scoped Styles(@scope
)を用いた場合
@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となります。
一方、@scope (.card-small)
のスコープで考えた場合には、スコープルートに対してのホップ数は1となります。
結果として、ネストしている.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
の適用範囲からは除外されます。
ポイントは@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に定義されています。そのうち一部を抜粋すると、次の順序となる旨の記述があります。
- Specificity
(詳細度) - 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
よりも詳細度で上回っています。このとき、見た目は次のようになります。
@scope
の有無にかかわらず、詳細度の順にスタイルが適用されていることがわかります。理解としては
余談ですが、Scoped Stylesと詳細度のどちらを優先して判断するかについては、仕様検討時にも長らく議論されていた点となります。興味があれば該当Issueにも目を通してみると理解が深まるかもしれません。
ブラウザでのサポート状況
今回紹介した機能について、本記事掲載時点でのメジャーブラウザにおける利用可能バージョンは次のとおりです。これ以降のバージョンであれば利用可能です。
機能 | Chrome | Edge | Safari | Firefox |
---|---|---|---|---|
Scoped Styles (@scope ) |
118 | 118 | 17. |
(未対応) |
なお、今回紹介したScoped Stylesは、本記事掲載時点では仕様的にはWorking Draftであり、今後仕様が変更される可能性がある点についてはご留意ください。
まとめ
影響範囲を閉じたスタイル適用は、従来ではBEMなどの設計手法に頼ったり、CSS ModulesやVue.@scope
でのScoped Stylesで実現可能となります。独自の設計ルールの策定をなくせたり、依存パッケージを減らせるといったメリットもあるため、コンポーネントのスタイリング時の選択肢の一つとして覚えておくとよいでしょう。