12k
All articles

@scope によるネイティブ CSS スコーピング

ネイティブCSSの@scopeは、セレクタをコンポーネントやドーナツ境界内に限定し、近接度ベースのカスケードをビルドなしで実現します。

OpenReplay Team
OpenReplay Team
@scope によるネイティブ CSS スコーピング

@scope は CSS のアットルールで、セレクターブロックがマッチする範囲を、ルート要素から任意の下限境界までに制限します。ビルドステップは不要で、詳細度も追加されません。MDN では Baseline「newly available」ステータス(2026 年 3 月付)とされています。

コンポーネントのコードベースを保守しているなら、スタイルのコンテインメントに対するコストをどこかで既に支払っているはずです。コードレビューで強制する命名規約、クラス名をハッシュ化するバンドラープラグイン、あるいは style タグを注入するランタイムなどです。これらが存在するのは、単純な子孫セレクターでは到達範囲が広すぎ、子セレクターを連結すると CSS が特定の DOM 構造に固く結び付いてしまうからです。

要点

  • @scope ブロック内の裸のセレクターは自身の詳細度のみを保持します。暗黙のプレフィックスが :where(:scope) であり、:where() は重みをまったく持たないためです。:scope を明示的に書くと 0-1-0 が加算されます。
  • @scope (.card) to (.card__content) は、カードとそのコンテンツスロットの間にある要素にマッチします。ルートは含まれ、limit 要素とその配下すべては除外されます。
  • スコープ付き宣言同士が詳細度で並んだ場合、要素からの DOM 上のホップ数が少ないスコープルートを持つ側が、ソース順に関係なく勝ちます。
  • スコーピングの近接性(proximity)は、重要度・カスケードレイヤー・詳細度の後、ソース順の前に比較されます。したがって詳細度の高いスコープ外セレクターは、依然としてスコープ付きルールを上書きします。
  • @scope はセレクターがマッチする範囲を制限するものであり、color のような継承プロパティがスコープの limit を越えて除外領域へ流れ込むのを止めるものではありません。

セレクターはなぜ漏れるのか。BEM、CSS Modules、CSS-in-JS はそれに対して何をしているのか

すべての CSS セレクターはドキュメント全体に対してマッチします。そのため「このカード内のヒーロー画像」を狙うと、構造に依存しすぎたセレクターと、範囲が広すぎるセレクターのどちらかを選ばざるを得なくなります。

.card > .card__body > img { }  /* 0-2-1, breaks when the markup moves */
img { }                        /* 0-0-1, matches every image on the page */
.card__img { }                 /* 0-1-0, BEM: a unique name per component */

BEM は命名規律によって到達範囲の問題を解決します。ブロック名を一意にし、内部のすべての要素に block__element クラスを付けることで、裸の .title が存在しないようにするわけです。CSS Modules は同じ考え方を自動化し、ビルド時に各クラス名をハッシュ化されたファイルローカルな識別子へ書き換えます。CSS-in-JS ライブラリはランタイムまたはコンパイル時に、コンポーネントのコードからそうした識別子を生成して同じことを行います(このエコシステムの現状については別記事で扱っています)。CSS Cascade Level 6 のドラフトは、これらのツールが内部でどう動いているかを明文化しています。コンポーネント内のすべての要素にマーカー属性やクラスを付け、ファイル内のすべてのセレクターにそのマーカーを追加するというものです。

アプローチビルドステップ下限境界(ドーナツ)カスケード内の近接性追加の詳細度
BEMなし命名規約のみなし名前ごとにクラスレベル
CSS Modulesありファイル単位のハッシュ化クラスなし名前ごとにクラスレベル
CSS-in-JSランタイムまたはコンパイルコンポーネントごとに生成されるクラスなし名前ごとにクラスレベル
@scopeなしネイティブの to (limit)ありルートからは追加なし

基本的な CSS スコープブロック: @scope (.card)

@scope (.card) { img { } }.card の包含子孫(自身を含む子孫)である <img> 要素のみにマッチし、.card というルートは img の詳細度に何も寄与しません。

<article class="card">
  <img src="hero.jpg" alt="">   <!-- matched -->
</article>
<img src="logo.svg" alt="">     <!-- not matched -->
@scope (.card) {
  img { border-radius: 8px; }        /* specificity 0-0-1 */
  :scope { padding: 1rem; }          /* specificity 0-1-0, the .card itself */
}

MDN のスコープ内での詳細度に関する記述がその仕組みを説明しています。ブロック内の裸のセレクターは、あたかも :where(:scope) というプレフィックスが前に置かれているかのようにマッチされ、:where() 自体は重みを持たないため、ルートは合計に何も加えません。これは属性スタンプ方式のツールが行うことの逆です。生成された [data-v-abc123] のフックは、それが付与されるすべてのセレクターに属性レベルの重みを追加します。一方、明示的に書いた :scope は通常の擬似クラスであり 0-1-0 を加算するため、:scope img は 0-1-1 になります。

ドーナツスコープとは何か

ドーナツスコープは @scope (.card) to (.card__content) のように書き、カードから .card__content の直前まで(.card__content とそのサブツリーは含まない)をスタイリングします。これはまさに、ネストしたコンポーネントが生み出すスロットコンテンツの問題そのものです。

<article class="card">
  <img src="hero.jpg" alt="">          <!-- in scope -->
  <div class="card__content">
    <img src="inline.jpg" alt="">      <!-- excluded: below the limit -->
  </div>
</article>
@scope (.card) to (.card__content) {
  img { border: 4px solid goldenrod; }
}

デフォルトでは、ルート自体はスコープ内と見なされ、limit 要素は見なされません。どちらかのセレクターに > * を付けると、その境界が反転します。@scope (.card) to (.card__content > *).card__content 要素そのものをスコープに含めつつ、その子要素は除外し続けます。これはスロットのラッパーにパディングが必要だが、スロットに差し込まれたコンテンツには手を付けたくない場合に有用です。ドラフトの定義では、要素はルートと同一またはその配下にあり、かつ limit そのものでもその配下でもない場合に対象となります。「X の子孫であるが Y の内部ではない」ことを表現できるセレクターの組み合わせは、すべての要素に境界用クラスを付けるか、詳細度を再導入してしまう :not() の連鎖を使うかしなければ存在しません。

スコーピングの近接性はソース順に勝る

2 つのスコープ付きルールが詳細度で並んだ場合、スコープルートが要素に最も近い宣言が勝ちます。これは、単純な子孫セレクターでは誤った結果になるネストしたテーマのバグを修正します。

<div class="theme-light">
  <p>Light</p>
  <div class="theme-dark">
    <p>Dark</p>
    <div class="theme-light">
      <p>Light again?</p>
    </div>
  </div>
</div>

通常のセレクターでは、最も内側の段落は .theme-light p.theme-dark p の両方に 0-1-1 でマッチするため、スタイルシート内で後に現れたルールが勝ち、ライトなコンテナの内部にあるにもかかわらずダークテーマの色で描画されてしまいます。

@scope (.theme-light) {
  p { color: #1b1b1b; }
}
@scope (.theme-dark) {
  p { color: #f2f2f2; }   /* declared later, but loses on the inner p */
}

最も内側の <p>.theme-light ルートから 1 ホップ、.theme-dark からは 2 ホップなので、ライトのルールが適用されます。MDN も同じ実例を解説しています。また仕様の近接性ルールの下では、スコーピングルートを持たないルールはこの競合に決して勝てません。そのホップ数は無限として扱われるためです。

近接性はカスケードのどこに位置するのか

スコーピングの近接性は、重要度・カスケードレイヤー・詳細度の後、ソース順の前に比較されます。したがって、詳細度の高いスコープ外セレクターは、スコープルートがどれほど近くても依然としてスコープ付きルールを上書きします。

Cascade 6 のソート順は、優先度の高い順に 7 つの基準を挙げています。

  1. オリジンと重要度
  2. コンテキスト(シャドウツリーのカプセル化)
  3. style 属性
  4. カスケードレイヤー
  5. 詳細度
  6. スコープの近接性
  7. 出現順

近接性は詳細度のタイブレーカーであり、その代替ではありません。

@scope (aside) {
  p { color: green; }      /* 0-0-1, scoped */
}
aside#sidebar p { color: red; }   /* 1-0-2, unscoped, wins */
<aside id="sidebar"><p>This is red.</p></aside>

近接性を詳細度より下にランク付けしたのは意図的な選択であり、ドラフトの変更ログには、詳細度を上回るはずだった強いバージョンが削除された記録が残っています。その理由はこの機能の背景にある解説に示されています。もし近接性が先に勝ってしまうと、詳細度は同じ近接性を持つセレクター間の競合しか決着させられなくなり、互いに上下関係を意図して書かれたルールが、代わりに DOM の形状によって勝ち負けを決められるようになってしまうのです。スコープ付きスタイルが Shadow DOM のカプセル化のように振る舞うことを期待しているなら、まさにここでその期待は崩れます。

セレクターはスコープされるが、継承はされない

@scope はセレクターがマッチできる範囲を制限しますが、継承プロパティがスコープの limit を越えて流れ込むのは止めません。そのため、スコープルートに設定した color は除外領域の内部にあるすべての要素にも届きます。

<article class="card">
  <p>Card text</p>
  <div class="card__content">
    <p>Slotted text: also hotpink, with no border</p>
  </div>
</article>
@scope (.card) to (.card__content) {
  :scope { color: hotpink; }             /* inherited: crosses the limit */
  p { border: 1px solid currentColor; }  /* not inherited, and p in the slot is out of scope */
}

スロット内の段落はスコープ外なので、どのスコープ付きセレクターもマッチせず、ボーダーは付きません。それでも hotpink で描画されます。継承はカスケードの後に動作するプロパティレベルの仕組みであり、スコープについて何も知らないからです。@scope のリファレンスも同じ点を指摘しています。スコーピングが囲い込むのは、セレクターが到達できる要素の範囲であって、結果として適用されたスタイルの行き先ではありません。スロット境界で封じ込めたいものは、従来と同じく、スロット自体に明示的なリセットを書く必要があります。

どのブラウザーが @scope をサポートし、いつ使うべきか

MDN は 2026 年 3 月時点で @scope を Baseline「newly available」としており、これは現行の主要エンジンすべてが実装済みであることを意味します。互換性データによれば、初期サポートは Chrome および Edge 118、Firefox 146 です。Safari は 17.4 で実装し、Safari 26.0 から 26.3 は partial とマークされ、Safari 26.4 で完全サポートが復帰しました。サポートしないブラウザーは、CSS が未認識の構文に対して要求するとおりアットルール全体を破棄するため、スコープ付きブロックは壊れたルールになるのではなく、何も残らない形で劣化します。

@scope を使うべきなのは、コンポーネントがツリーの一領域を所有しつつ、そこにスロットとして差し込まれたものをスタイリングしてはならない場合、あるいは同じコンポーネントが異なるバリアントで自身の内部にネストする場合です。グローバルリセット、タイポグラフィ、ブランドトークンには使わないでおきましょう。これらは全体に流れていくべきものであり、カスケードはまさにそうなるように設計されています。サブツリーを囲い込むのではなく、スタイルシート全体同士の順序付けが必要な場合は、引き続きカスケードレイヤーが適切なツールです。

@scope は、コンテインメントをビルドパイプラインからブラウザーへと移します。ドーナツ境界と近接性の解決は、いまや規約や生成されたハッシュではなく、カスケードの機能です。実践的な次の一歩は、内部スロットに手を付けないようにするために現在 __element サフィックスやハッシュ化クラスに依存しているコンポーネントを 1 つ選び、それを @scope (.component) to (.slot) ブロックとして書き直し、スロットで止まると期待していた色やフォントがそこで明示的にリセットされているか確認することです。

FAQ

@supports で @scope を機能検出できますか。また、サポートしないブラウザーでは何が起こりますか?

@scope をサポートしないブラウザーはアットルールブロックを破棄するため、スコープ付きルールは適用されず、それ以外が壊れることもありません。CSS Conditional Rules Level 5 は @supports at-rule(@scope) を定義していますが、at-rule() は @scope より新しく(Chromium 148 が最初に実装)、@scope を欠くほど古いブラウザーはこの検出関数も持ちません。同等かそれ以下の詳細度を持つ素のフォールバックルールをブロックの外に書いてください。サポートするブラウザーはそれをスコープ付きルールで上書きします。

ルートセレクターなしで @scope を使えますか?

はい。HTML の style 要素の内部では、プレリュードにルートセレクターを書かずに @scope と記述でき、ブラウザーは囲まれたルールをその style 要素の親要素にスコープします。このインライン形式は limit も受け付け、@scope to (.card__content) のように書きます。これは自身のスタイルを同梱するサーバーレンダリングされたフラグメントに適しています。通常のスタイルシートでは、スコープルートが明示的になる (root) プレリュード形式を使ってください。

JavaScript から @scope ルールを読み取れますか?

はい。CSSGroupingRule を継承する CSSScopeRule インターフェイスを通じて可能です。読み取り専用の文字列プロパティを 2 つ公開しており、start はシリアライズされたスコープルートセレクター、end はスコープ limit セレクターを返します。プレリュードのその部分が省略されている場合はそれぞれ null になります。ルールへは document.styleSheets とその cssRules リスト経由でアクセスし、その内部のスコープ付きスタイルルールには CSSGroupingRule から継承した cssRules プロパティでアクセスします。

スタイルのカプセル化において @scope と Shadow DOM の違いは何ですか?

Shadow DOM は独立した DOM ツリーを作り、強固なスタイル境界を設けます。外側のセレクターは ::part を除いてシャドウルート内部にマッチできず、内部のルールも外へ到達できません。@scope はマークアップを何も変えません。1 つのブロック内のセレクターがマッチできる範囲だけを制限するので、他のスタイルシートは依然としてスコープ内のすべての要素を対象にできます。継承プロパティはどちらの境界も越えます。また @scope は JavaScript もシャドウルートも必要としません。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.