12k
All articles

Isolation CSS native avec @scope

@scope en CSS natif limite les sélecteurs à un composant ou à une zone donut, avec une cascade basée sur la proximité et sans build.

OpenReplay Team
OpenReplay Team
Isolation CSS native avec @scope

@scope est une règle-at CSS qui restreint la portée de correspondance d’un bloc de sélecteurs, depuis un élément racine jusqu’à une limite inférieure optionnelle, sans étape de build ni spécificité supplémentaire. MDN lui attribue le statut Baseline « newly available » daté de mars 2026.

Si vous maintenez une base de code à composants, vous payez déjà le prix du confinement des styles quelque part : une convention de nommage que vous faites respecter en revue de code, un plugin de bundler qui hache les noms de classes, ou un runtime qui injecte des balises style. Chacun de ces mécanismes existe parce qu’un simple sélecteur de descendant porte trop loin, et qu’un sélecteur d’enfant chaîné soude le CSS à une forme de DOM bien précise.

Points clés à retenir

  • À l’intérieur d’un bloc @scope, un sélecteur nu conserve uniquement sa propre spécificité, car le préfixe implicite est :where(:scope) et :where() ne contribue à aucun poids ; écrire :scope explicitement ajoute 0-1-0.
  • @scope (.card) to (.card__content) fait correspondre les éléments situés entre la carte et son emplacement de contenu : la racine est incluse, l’élément limite et tout ce qui se trouve en dessous sont exclus.
  • Lorsque deux déclarations à portée limitée sont à égalité de spécificité, celle dont la racine de portée est la moins éloignée de l’élément dans le DOM l’emporte, quel que soit l’ordre d’apparition dans la source.
  • La proximité de portée est comparée après l’importance, les couches de cascade et la spécificité, et avant l’ordre d’apparition : un sélecteur non limité mais de spécificité supérieure prend donc toujours le dessus sur une règle à portée limitée.
  • @scope limite l’endroit où les sélecteurs peuvent correspondre ; il n’empêche pas les propriétés héritées comme color de franchir la limite de portée pour atteindre la zone exclue.

Pourquoi les sélecteurs débordent-ils, et comment BEM, CSS Modules et CSS-in-JS y remédient-ils ?

Chaque sélecteur CSS s’applique à l’ensemble du document : cibler « l’image hero de cette carte » impose donc de choisir entre un sélecteur trop structurel et un sélecteur trop large.

.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 résout le problème de portée par la discipline de nommage : un nom de bloc unique, et chaque élément interne porte une classe block__element, de sorte qu’un simple .title n’existe jamais. Les CSS Modules automatisent la même idée en réécrivant chaque nom de classe en un identifiant haché, local au fichier, au moment du build. Les bibliothèques CSS-in-JS font de même à l’exécution ou à la compilation, en générant ces identifiants à partir du code de vos composants ; l’état actuel de cet écosystème fait l’objet d’un article distinct. Le brouillon CSS Cascade Level 6 détaille le fonctionnement interne de ces outils : ils marquent chaque élément d’un composant à l’aide d’un attribut ou d’une classe, puis ajoutent ce marqueur à chaque sélecteur du fichier.

ApprocheÉtape de buildLimite inférieure (donut)Proximité dans la cascadeSpécificité ajoutée
BEMNonPar convention de nommage uniquementNonNiveau classe, par nom
CSS ModulesOuiClasses hachées par fichierNonNiveau classe, par nom
CSS-in-JSExécution ou compilationClasse générée par composantNonNiveau classe, par nom
@scopeNonClause native to (limit)OuiAucune, depuis la racine

Le bloc de portée CSS de base : @scope (.card)

@scope (.card) { img { } } ne cible que les éléments <img> qui sont des descendants inclusifs d’un .card, et la racine .card n’ajoute rien à la spécificité de 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 */
}

Les notes de MDN sur la spécificité au sein d’une portée expliquent le mécanisme. Un sélecteur nu dans le bloc est évalué comme si le préfixe :where(:scope) le précédait et, puisque :where() n’a aucun poids propre, la racine n’ajoute rien au total. C’est l’inverse de ce que font les outils d’estampillage par attribut : un hook généré [data-v-abc123] ajoute un poids de niveau attribut à chaque sélecteur qu’il touche. Écrit explicitement, :scope est une pseudo-classe ordinaire qui ajoute 0-1-0 : :scope img vaut donc 0-1-1.

Qu’est-ce qu’une portée en donut ?

Une portée en donut, écrite @scope (.card) to (.card__content), stylise tout, de la carte jusqu’à .card__content exclu ainsi que son sous-arbre : c’est exactement le problème du contenu inséré (slotted content) que créent les composants imbriqués.

<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; }
}

Par défaut, la racine elle-même est comprise dans la portée, contrairement à l’élément limite. Ajouter > * à l’un ou l’autre sélecteur inverse cette frontière. @scope (.card) to (.card__content > *) fait entrer l’élément .card__content lui-même dans la portée tout en continuant d’exclure ses enfants, ce qui est utile lorsque le conteneur d’emplacement a besoin d’un padding mais que le contenu inséré doit rester intact. Dans la définition du brouillon, un élément est éligible lorsqu’il se situe au niveau de la racine ou en dessous, et qu’il n’est ni une limite ni situé sous une limite. Aucune combinaison de sélecteurs n’exprime « descendant de X mais pas à l’intérieur de Y » sans recourir soit à une classe de frontière sur chaque élément, soit à des chaînes de :not() qui réintroduisent de la spécificité.

La proximité de portée l’emporte sur l’ordre d’apparition

Lorsque deux règles à portée limitée sont à égalité de spécificité, la déclaration dont la racine de portée est la plus proche de l’élément l’emporte, ce qui corrige le bug des thèmes imbriqués que les simples sélecteurs de descendant gèrent mal.

<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>

Avec des sélecteurs ordinaires, le paragraphe le plus interne correspond à la fois à .theme-light p et à .theme-dark p avec une spécificité de 0-1-1 : la règle qui apparaît en dernier dans la feuille de style l’emporte, et le paragraphe s’affiche dans la couleur du thème sombre alors même qu’il se trouve dans un conteneur clair.

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

Le <p> le plus interne se trouve à un saut de sa racine .theme-light et à deux sauts de .theme-dark : c’est donc la règle claire qui s’applique. MDN détaille le même exemple pas à pas et, selon la règle de proximité de la spécification, une règle sans racine de portée ne peut jamais remporter cet arbitrage, car son nombre de sauts est considéré comme infini.

Où se situe la proximité dans la cascade ?

La proximité de portée est comparée après l’importance, les couches de cascade et la spécificité, et avant l’ordre d’apparition : un sélecteur non limité de spécificité supérieure prend donc toujours le dessus sur une règle à portée limitée, quelle que soit la proximité de la racine de portée.

L’ordre de tri de Cascade 6 énumère sept critères par ordre de priorité décroissante :

  1. Origine et importance
  2. Contexte (encapsulation par arbre d’ombre)
  3. L’attribut style
  4. Couches de cascade
  5. Spécificité
  6. Proximité de portée
  7. Ordre d’apparition

La proximité départage les égalités de spécificité, elle ne la remplace pas :

@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>

Placer la proximité en dessous de la spécificité est un choix délibéré, et le journal des modifications du brouillon consigne la suppression de la version plus forte qui l’aurait devancée. Le raisonnement figure dans l’explainer à l’origine de la fonctionnalité : si la proximité l’emportait d’abord, la spécificité ne trancherait plus que les conflits entre sélecteurs de proximité identique, et des règles écrites pour se surclasser mutuellement se mettraient à gagner ou perdre en fonction de la forme du DOM. Si vous attendez des styles à portée limitée qu’ils se comportent comme l’encapsulation du Shadow DOM, c’est ici que cette attente est démentie.

Les sélecteurs sont limités en portée ; l’héritage ne l’est pas

@scope limite l’endroit où un sélecteur peut correspondre ; il n’empêche pas les propriétés héritées de franchir la limite de portée : une color définie sur la racine de portée atteint donc tous les éléments de la zone exclue.

<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 */
}

Le paragraphe inséré est hors de la portée : aucun sélecteur à portée limitée ne lui correspond et il ne reçoit donc pas de bordure. Il s’affiche malgré tout en hotpink, car l’héritage est un mécanisme au niveau des propriétés, qui intervient après la cascade et ignore tout des portées. La référence @scope fait le même constat : la limitation de portée délimite les éléments qu’un sélecteur peut atteindre, pas l’endroit où aboutissent les styles qui en résultent. Tout ce que vous souhaitez confiner à la frontière de l’emplacement nécessite une réinitialisation explicite sur l’emplacement lui-même, comme cela a toujours été le cas.

Quels navigateurs prennent en charge @scope, et quand faut-il l’utiliser ?

MDN classe @scope en Baseline « newly available » depuis mars 2026, ce qui signifie que tous les moteurs majeurs actuels le livrent. Les données de compatibilité indiquent une première prise en charge dans Chrome et Edge 118 ainsi que Firefox 146 ; Safari 17.4 l’a livrée, Safari 26.0 à 26.3 sont marqués comme partiels, et Safari 26.4 a rétabli la prise en charge complète. Les navigateurs qui ne la prennent pas en charge ignorent la règle-at dans son intégralité, comme CSS l’exige pour les constructions non reconnues : un bloc à portée limitée se dégrade donc en rien du tout plutôt qu’en une règle cassée.

Recourez à @scope lorsqu’un composant possède une région de l’arbre mais ne doit pas styliser ce qui y est inséré, ou lorsqu’un même composant s’imbrique en lui-même avec des variantes différentes. Abstenez-vous pour les resets globaux, la typographie et les tokens de marque : ceux-ci doivent se propager, et la cascade est conçue pour cela. Lorsque vous devez ordonner des feuilles de style entières les unes par rapport aux autres plutôt que cloisonner des sous-arbres, les couches de cascade restent l’outil approprié.

@scope déplace le confinement de votre pipeline de build vers le navigateur : les frontières en donut et la résolution par proximité sont désormais des fonctionnalités de la cascade, et non plus des conventions ou des hachages générés. L’étape suivante, concrètement, consiste à prendre un composant qui repose aujourd’hui sur un suffixe __element ou une classe hachée pour préserver son emplacement interne, à le réécrire sous la forme d’un bloc @scope (.component) to (.slot), puis à vérifier que toute couleur ou police censée s’arrêter à l’emplacement y est bien réinitialisée explicitement.

FAQ

Puis-je détecter la prise en charge de @scope avec @supports, et que se passe-t-il dans les navigateurs qui ne le supportent pas ?

Les navigateurs sans prise en charge de @scope ignorent le bloc de la règle-at : les règles à portée limitée ne s'appliquent pas et rien d'autre ne casse. CSS Conditional Rules Level 5 définit @supports at-rule(@scope), mais at-rule() est plus récent que @scope (Chromium 148 l'a livré en premier) : tout navigateur assez ancien pour ne pas connaître @scope ne dispose donc pas non plus de la fonction de détection. Écrivez des règles de repli simples, de spécificité égale ou inférieure, en dehors du bloc ; les navigateurs compatibles les remplaceront par les règles à portée limitée.

Puis-je utiliser @scope sans sélecteur racine ?

Oui. À l'intérieur d'un élément style HTML, vous pouvez écrire @scope sans sélecteur racine dans son prélude : le navigateur limite alors la portée des règles encadrées à l'élément parent de cet élément style. La forme en ligne accepte également une limite, écrite @scope to (.card__content). Cela convient aux fragments rendus côté serveur qui embarquent leurs propres styles. Dans les feuilles de style ordinaires, utilisez la forme de prélude (root) afin que la racine de portée soit explicite.

Puis-je lire les règles @scope depuis JavaScript ?

Oui, via l'interface CSSScopeRule, qui étend CSSGroupingRule. Elle expose deux propriétés chaîne en lecture seule : start renvoie le sélecteur de racine de portée sérialisé et end renvoie le sélecteur de limite de portée, chacune valant null lorsque la partie correspondante du prélude est omise. Accédez à une règle via document.styleSheets et sa liste cssRules, puis aux règles de style à portée limitée qu'elle contient via la propriété cssRules héritée de CSSGroupingRule.

Quelle est la différence entre @scope et le Shadow DOM pour l'encapsulation des styles ?

Le Shadow DOM crée un arbre DOM distinct doté d'une frontière de style stricte : les sélecteurs externes ne peuvent pas correspondre à l'intérieur d'une racine d'ombre, sauf via ::part, et les règles internes ne peuvent pas en sortir. @scope ne change rien au balisage ; il se contente de limiter l'endroit où les sélecteurs d'un bloc peuvent correspondre, si bien que d'autres feuilles de style peuvent toujours cibler chaque élément de la portée. Les propriétés héritées franchissent les deux frontières. @scope ne nécessite en outre ni JavaScript ni racine d'ombre.

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.