Нативная изоляция стилей в CSS с помощью @scope
Нативный CSS @scope ограничивает селекторы компонентом или donut boundary, с каскадом по близости и без сборки.
@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 перечисляет семь критериев в порядке убывания приоритета:
- Происхождение и важность
- Контекст (инкапсуляция теневого дерева)
- Атрибут style
- Каскадные слои
- Специфичность
- Близость области видимости
- Порядок появления
Близость — это способ разрешения ничьей по специфичности, а не её замена:
@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.
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