12k
All articles

Нативная изоляция стилей в CSS с помощью @scope

Нативный CSS @scope ограничивает селекторы компонентом или donut boundary, с каскадом по близости и без сборки.

OpenReplay Team
OpenReplay Team
Нативная изоляция стилей в CSS с помощью @scope

@scope — это CSS at-rule, которое ограничивает область применения блока селекторов: от корневого элемента вниз по дереву до необязательной нижней границы, без этапа сборки и без добавления специфичности. MDN присваивает ему статус Baseline «newly available» начиная с марта 2026 года.

Если вы поддерживаете кодовую базу с компонентами, вы уже так или иначе платите за изоляцию стилей: соглашением об именовании, которое приходится контролировать на код-ревью, плагином сборщика, хеширующим имена классов, или рантаймом, внедряющим теги style. Каждое из этих решений существует потому, что обычный селектор потомка охватывает слишком многое, а цепочка дочерних селекторов намертво привязывает CSS к одной конкретной структуре DOM.

Ключевые выводы

  • Внутри блока @scope голый селектор сохраняет только собственную специфичность, поскольку подразумеваемый префикс — это :where(:scope), а :where() не добавляет веса; явная запись :scope добавляет 0-1-0.
  • @scope (.card) to (.card__content) сопоставляется с элементами между карточкой и её слотом для контента: корень включается, элемент-граница и всё, что ниже него, исключаются.
  • Когда две изолированные декларации имеют одинаковую специфичность, побеждает та, чей корень области видимости находится ближе к элементу по числу переходов в DOM, независимо от порядка в исходном коде.
  • Близость области видимости (scoping proximity) сравнивается после важности, каскадных слоёв и специфичности, но до порядка в исходном коде, поэтому неизолированный селектор с более высокой специфичностью всё равно переопределит изолированное правило.
  • @scope ограничивает только то, где сопоставляются селекторы; он не препятствует наследуемым свойствам вроде color проникать за нижнюю границу области видимости в исключённую зону.

Почему селекторы «протекают» и что с этим делают 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 { } } сопоставляется только с элементами <img>, которые являются включающими потомками .card, причём корень .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.

Что такое «пончиковая» область видимости (donut scope)?

«Пончиковая» область видимости, записываемая как @scope (.card) to (.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; }
}

По умолчанию сам корень считается входящим в область видимости, а элемент-граница — нет. Добавление > * к любому из селекторов сдвигает эту границу. @scope (.card) to (.card__content > *) включает сам элемент .card__content в область видимости, продолжая при этом исключать его потомков, что полезно, когда обёртке слота нужен внутренний отступ, а вложенный контент трогать нельзя. Согласно определению из черновика, элемент подходит, если он находится на уровне корня или ниже и при этом сам не является границей и не находится ниже границы. Никакая комбинация селекторов не выражает «потомок X, но не внутри Y» без либо класса-границы на каждом элементе, либо цепочек :not(), которые снова повышают специфичность.

Близость области видимости важнее порядка в исходном коде

Когда два изолированных правила имеют одинаковую специфичность, побеждает та декларация, чей корень области видимости ближе к элементу, — это устраняет баг с вложенными темами, который обычные селекторы потомков обрабатывают неверно.

<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 и двух — от .theme-dark, поэтому применяется светлое правило. MDN разбирает тот же самый пример, а по правилу близости из спецификации правило без корня области видимости никогда не сможет выиграть это состязание, поскольку число его переходов считается бесконечным.

Какое место близость занимает в каскаде?

Близость области видимости сравнивается после важности, каскадных слоёв и специфичности, но до порядка в исходном коде, поэтому неизолированный селектор с более высокой специфичностью всё равно переопределит изолированное правило, каким бы близким ни был корень области видимости.

Порядок сортировки в Cascade 6 перечисляет семь критериев в порядке убывания приоритета:

  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 ограничивает то, где селектор может сопоставляться; он не препятствует наследуемым свойствам проникать за нижнюю границу области видимости, поэтому 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 присваивает @scope статус Baseline «newly available» по состоянию на март 2026 года, а значит, его поддерживают все актуальные основные движки. Данные о совместимости показывают первую поддержку в Chrome и Edge 118 и Firefox 146; Safari 17.4 добавил его, версии Safari с 26.0 по 26.3 отмечены как частично поддерживающие, а Safari 26.4 восстановил полную поддержку. Браузеры, в которых его нет, отбрасывают at-rule целиком, как того требует CSS для нераспознанных конструкций, поэтому изолированный блок деградирует до полного отсутствия стилей, а не до сломанного правила.

Прибегайте к @scope, когда компонент владеет некоторой областью дерева, но не должен стилизовать то, что в него вкладывается через слот, или когда один и тот же компонент вкладывается сам в себя с разными вариантами. Не используйте его для глобальных сбросов, типографики и брендовых токенов: они должны свободно распространяться, и каскад устроен именно так, чтобы это работало. Там, где нужно упорядочить целые таблицы стилей относительно друг друга, а не отгородить поддеревья, правильным инструментом остаются каскадные слои.

@scope переносит изоляцию из вашего сборочного конвейера в браузер: «пончиковые» границы и разрешение по близости теперь являются возможностями каскада, а не соглашениями или сгенерированными хешами. Практический следующий шаг — взять один компонент, который сейчас полагается на суффикс __element или хешированный класс, чтобы не трогать свой внутренний слот, переписать его как блок @scope (.component) to (.slot) и проверить, что любой цвет или шрифт, который вы рассчитывали остановить на границе слота, там явно сброшен.

Часто задаваемые вопросы

Можно ли определить поддержку @scope через @supports и что происходит в браузерах без неё?

Браузеры без поддержки @scope отбрасывают блок at-rule, поэтому изолированные правила не применяются, и больше ничего не ломается. CSS Conditional Rules Level 5 определяет @supports at-rule(@scope), но at-rule() появился позже @scope (первым его выпустил Chromium 148), поэтому любой браузер, достаточно старый, чтобы не поддерживать @scope, не поддерживает и функцию определения. Пишите обычные запасные правила с равной или меньшей специфичностью вне блока; поддерживающие браузеры переопределят их изолированными правилами.

Можно ли использовать @scope без корневого селектора?

Да. Внутри HTML-элемента style можно написать @scope без корневого селектора в прелюдии, и браузер ограничит вложенные правила родительским элементом этого элемента style. Встроенная форма также принимает нижнюю границу, записываемую как @scope to (.card__content). Это подходит для отрендеренных на сервере фрагментов, которые поставляются вместе со своими стилями. В обычных таблицах стилей используйте форму прелюдии (root), чтобы корень области видимости был задан явно.

Можно ли читать правила @scope из JavaScript?

Да, через интерфейс CSSScopeRule, который расширяет CSSGroupingRule. Он предоставляет два строковых свойства только для чтения: start возвращает сериализованный селектор корня области видимости, а end — селектор нижней границы, причём каждое из них равно null, если соответствующая часть прелюдии опущена. Доберитесь до правила через document.styleSheets и его список cssRules, а к изолированным правилам стилей внутри него обращайтесь через свойство cssRules, унаследованное от CSSGroupingRule.

В чём разница между @scope и Shadow DOM с точки зрения инкапсуляции стилей?

Shadow DOM создаёт отдельное DOM-дерево с жёсткой границей стилей: внешние селекторы не могут сопоставляться внутри shadow root, кроме как через ::part, а внутренние правила не могут дотянуться наружу. @scope ничего не меняет в разметке; он лишь ограничивает то, где могут сопоставляться селекторы внутри одного блока, поэтому другие таблицы стилей по-прежнему могут нацеливаться на любой элемент в области видимости. Наследуемые свойства пересекают обе границы. Кроме того, @scope не требует ни JavaScript, ни shadow root.

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.