Представим панель на дашборде: у неё есть шапка с вкладками, подписи, ссылки — и пустое место посередине, куда подставляется чужой виджет. Это может быть прогноз погоды, график продаж или карточка пользователя. Такое место называют слотом: панель отвечает за рамку вокруг, а содержимое слота живёт своей жизнью и пишет собственные стили.
Проблема всплывает, как только панели нужен свой цвет текста. Пишем .panel { color: darkorange; } — и оранжевым становится всё, включая виджет в слоте, который об этом не просил. Свойство color наследуется, а селекторы вроде .panel p достают любой абзац внутри панели, на какой бы глубине он ни лежал.
<section class="panel">
<nav class="panel-tabs"><a href="#">Сегодня</a> <a href="#">Неделя</a></nav>
<p>Обновлено 5 минут назад</p>
<div class="slot">
<!-- сюда подставляется чужой виджет -->
<article class="weather">
<h3>Kyiv</h3>
<p>+18°, облачно</p>
</article>
</div>
</section>
body {
color: #1d2a44;
}
.panel {
color: darkorange; /* перекрасит и панель, и погоду в слоте */
}
Нужен способ сказать: «стили действуют от панели и вниз, но останавливаются на слоте». У области появляется верхняя граница и нижняя, а внутри — дыра. Ещё в 2011 году Николь Салливан назвала такую область «бубликом» (scope donut), и термин прижился. Сейчас для этого есть отдельное at-правило @scope, но сначала посмотрим, как без него выкручивались и почему этого не хватало.
Как обходились без @scope
Перебить специфичностью
Самый частый подход — смириться с тем, что стили панели протекают, и перебивать их в каждом виджете. Панель пишет общие правила, виджет — свои, и выигрывает тот, у кого селектор весомее или кто объявлен позже:
.panel {
color: darkorange;
}
.weather {
color: #1d2a44; /* возвращаем свой цвет обратно */
}
На двух компонентах это терпимо. На двадцати каждый виджет начинает заранее защищаться от всех возможных родителей, а любая правка панели может внезапно поменять вид чего-то в другой части интерфейса. Виджет к тому же ведёт себя по-разному в зависимости от того, в какую панель его вставили.
Отсечь слот через :not()
Второй вариант — красить не саму панель, а её детей, кроме слота:
.panel > :not(.slot) {
color: darkorange;
}
Вкладки и подпись становятся оранжевыми, слот остаётся нетронутым. Но у решения есть подводный камень: комбинатор > ограничивает его прямыми детьми панели. Стоит обернуть слот ещё одним блоком, например колонкой сетки, и обёртка сама попадёт под правило, а её цвет по наследству уйдёт в слот. Убрать > тоже нельзя: .panel :not(.slot) заденет и элементы внутри виджета, ведь они тоже «не слот».
С тех пор как браузеры научились принимать сложные селекторы внутри :not(), появился трюк вида .panel :not(.slot *) — его показывала Лия Веру. Он исключает всё, что лежит внутри слота, но сам слот и всех его предков в панели по-прежнему выбирает, так что наследуемые свойства протекают точно так же. Такую область называют мелким бубликом: она работает, только когда заранее известно, на какой глубине окажется слот.
Синтаксис @scope: корень и граница
At-правило @scope принимает до двух селекторов. Первый задаёт корень области (scope root) — элемент, от которого стили начинают действовать. Второй, после ключевого слова to, — границу (scope limit), на которой они останавливаются:
/* только корень: всё внутри .panel */
@scope (.panel) {
p { margin-block: 0.5em; }
}
/* корень и граница: внутри .panel, но не внутри .slot */
@scope (.panel) to (.slot) {
a, p, h3 { color: darkorange; }
}
Граница находит слот на любой глубине: прямой ли это ребёнок панели, лежит ли он в колонке сетки или во вкладке внутри вкладки. Ровно то, чего не умел :not().
Что важно запомнить про границы:
- Корень входит в область, граница — нет. Элемент-граница и всё, что внутри него, правилами блока не выбираются.
- Корней и границ может быть несколько — через запятую, как в обычном списке селекторов: @scope (main) to (aside, nav) красит всё в <main>, кроме боковой колонки и навигации.
- Корень — любой селектор, не только класс. Например, @scope (.card:has(> img)) ограничит стили карточками, у которых есть картинка.
Как обратиться к самому корню
Голый селектор внутри блока ищет элементы внутри корня, а не сам корень: правило section { ... } в @scope (section) сработает только на вложенных секциях. Чтобы выбрать сам корневой элемент, есть псевдокласс :scope, а с ним работают также & и комбинатор в начале селектора:
@scope (.panel) to (.slot) {
:scope {
padding: 16px; /* сама панель */
border: 1px solid #d0d7e2;
}
& > nav { display: flex; } /* то же, что :scope > nav */
> p { font-size: 14px; } /* комбинатор в начале — тоже от корня */
}
Граница только для прямых детей
Иногда мелкий бублик и нужен: остановиться только на слотах, которые лежат прямо в панели, а глубокие оставить в области. Для этого :scope пишут прямо в селекторе границы:
@scope (.panel) to (:scope > .slot) {
* { color: darkorange; }
}
Обратите внимание на * — здесь он уместен, а в обычном бублике с ним легко ошибиться. Об этом следующий раздел.
Главная ловушка: граница отсекает селекторы, но не наследование
@scope ограничивает, какие элементы выберут селекторы. Каскад и наследование он не выключает. Если элемент внутри области получил наследуемое свойство — color, font-family, line-height, — его дети, включая слот за границей, унаследуют значение как обычно.
Отсюда классическая ошибка: написать в бублике универсальный селектор.
/* так виджет всё равно станет оранжевым */
@scope (.panel) to (.slot) {
* { color: darkorange; }
}
Сам .slot правило не выбирает, зато выбирает его родителя — колонку сетки, внутри которой слот лежит. Колонка становится оранжевой, слот наследует цвет от неё, виджет — от слота. Тот же эффект даст :scope { color: darkorange; }: цвет на корне уходит вниз по наследству сквозь любую границу.
Рабочих способов два:
- Красить конечные элементы, а не контейнеры. Перечислить в бублике то, что реально несёт текст панели: h2, p, a, span. Обёртки при этом цвет не получают, и наследовать слоту нечего.
- Зафиксировать наследуемые свойства на самой границе. Если панели всё-таки нужен цвет на корне, слоту задают свой отдельным правилом вне блока — например, .slot { color: #1d2a44; } — и наследование обрывается на нём.
Ещё одно ограничение того же рода: правила внутри @scope не выходят за корень. Селекторы вида :scope + ul или :scope ~ p ничего не выберут, потому что соседи корня лежат за пределами области.
Какой вес у селекторов внутри @scope
Селектор корня к весу правил внутри блока не добавляется. Голый селектор и & работают так, будто перед ними стоит :where(:scope), а у :where() специфичность нулевая. Явный :scope — другое дело: это псевдокласс, и он весит как класс.
@scope (#sidebar .panel) {
img { } /* 0-0-1: id и класс корня не считаются */
& img { } /* 0-0-1: & здесь тоже ничего не добавляет */
:scope img { } /* 0-1-1: :scope весит как класс */
}
Поэтому стили внутри @scope легко перебить снаружи — это сознательное решение, а не баг. Учтите, что про & браузеры договорились не сразу: Chrome до 143-й версии и Safari до 26.2 давали ему вес селектора корня. Если правило в бублике должно гарантированно перебить что-то снаружи, добавляйте вес явно через :scope, а не рассчитывайте на &. Подробнее про обнуление веса — в статье о псевдоклассах :is и :where.
Близость: когда побеждает ближайший корень
У @scope есть второе применение, не связанное со слотами. Оно добавило в каскад новый критерий — близость к корню (scoping proximity). Если два правила совпали по слоям и специфичности, раньше решал порядок в файле: кто ниже, тот и прав. Теперь перед порядком проверяется, чей корень ближе к элементу по дереву DOM — то есть сколько уровней вверх до него подниматься.
Полный порядок проверки выглядит так: важность (!important) → каскадные слои → специфичность → близость → порядок объявления.
Вложенные темы
Классический пример — светлая и тёмная тема на классах, которые можно вкладывать друг в друга: страница светлая, подвал тёмный, а внутри подвала светлая плашка с подпиской.
.theme-dark a { color: #8ab4ff; }
.theme-light a { color: #1a56c4; }
У обоих селекторов вес 0-1-1, поэтому любая ссылка внутри двух тем получает цвет того правила, что ниже в файле. В тёмном подвале на светлой странице ссылки станут тёмно-синими на чёрном фоне. Переставить правила местами не поможет: сломается обратный случай, светлая плашка внутри тёмного подвала. С @scope побеждает ближайшая тема, и порядок перестаёт иметь значение:
@scope (.theme-dark) {
:scope { background: #121826; color: #e8ecf4; }
a { color: #8ab4ff; }
}
@scope (.theme-light) {
:scope { background: #ffffff; color: #1d2a44; }
a { color: #1a56c4; }
}
Модификаторы, которым всё равно, где они в файле
Тот же механизм лечит частую ошибку с классами-модификаторами. Карточка-модификатор .card-wide должна перебить базовую .card, но если она оказалась выше в файле, при равной специфичности выигрывает база. Порядок обычно держат в голове, но сборщик, ленивая загрузка или разбивка CSS на чанки (отдельные файлы, которые грузятся по мере надобности) могут его перемешать — и модификатор, который работал локально, перестаёт работать на продакшене.
/* модификатор объявлен первым — и всё равно перебьёт базу */
@scope (.card-wide) {
:scope { grid-column: span 2; }
:scope img { aspect-ratio: 21 / 9; } /* 0-1-1 */
}
/* обычное правило без @scope */
.card img { aspect-ratio: 4 / 3; } /* 0-1-1 */
Специфичность у двух правил одинаковая, и раньше выиграло бы то, что ниже, то есть база. Но у правила без @scope корня нет вовсе, и каскад считает его близость бесконечной. Любое правило из области, даже с далёким корнем, оказывается ближе. Поэтому модификатор побеждает, где бы ни лежал в файле.
Здесь легко ошибиться в двух местах. Первое: если внутри блока написать голый img, его вес будет 0-0-1, и база выиграет ещё по специфичности, до всякой близости. Второе: если завернуть в @scope (.card) и базу тоже, оба корня окажутся на одном элементе <div class="card card-wide">. Близость сравняется, и всё снова решит порядок в файле.
@scope прямо в разметке
Если @scope без селекторов положить в тег <style> внутри разметки, корнем станет родитель этого тега. Стили компонента живут рядом с его HTML и действуют только на него:
<section class="promo">
<style>
@scope {
:scope { padding: 24px; background: #fff4e0; }
h2 { color: #b45309; }
}
</style>
<h2>Скидка на годовую подписку</h2>
<p>Только до конца месяца.</p>
</section>
<h2>Этот заголовок не станет коричневым</h2>
Плюсы у такого подхода практические. Отдельный файл скачивать не нужно, а стили приходят вместе с разметкой, поэтому страница не прыгает, когда CSS догружается позже HTML. Этот сдвиг вёрстки называют CLS (Cumulative Layout Shift), и Google учитывает его в метриках скорости. Минусы тоже понятные: если компонент повторяется на странице десять раз, его стили продублируются десять раз, а браузер не может закэшировать их отдельно от HTML. Разумный баланс — общие стили во внешнем файле, а встроенный @scope для уникальных блоков, которые встречаются один раз на странице.
Если в <style> указать селектор, например @scope (.promo), он поведёт себя как обычное правило из внешнего файла. Неявный корень появляется, только когда селекторов нет.
Когда брать @scope, а когда нет
Ограничивать стили компонента умеют и без @scope. Чем отличаются основные подходы:
| Подход | Как ограничивает стили | Чего стоит |
| BEM-именование | Уникальные классы вида panel__tab--active, договорённость команды | Длинные имена, дисциплина, при смене разметки — переименования |
| CSS Modules, CSS-in-JS | Сборщик дописывает к классам хэш, классы не пересекаются | Нужен билд, в DevTools вместо имён — хэши |
| Shadow DOM | Внешние селекторы не попадают внутрь компонента, а его селекторы не выходят наружу | Нужен веб-компонент, стилизация снаружи только через кастомные свойства и ::part() |
| @scope | Ограничивает собственные селекторы корнем и границей | Защищает только в одну сторону: чужие глобальные стили по-прежнему доходят до компонента |
Последняя строка — главное, что стоит понимать: @scope не изоляция, а ограничение собственного влияния. Если нужна настоящая стена в обе стороны, это задача для Shadow DOM. Зато @scope не требует ни сборщика, ни веб-компонентов и позволяет писать внутри области короткие селекторы по тегам вместо длинных классов:
/* вместо .user-card__avatar, .user-card__name, .user-card__role */
@scope (.user-card) {
img { width: 40px; border-radius: 50%; }
h3 { font-size: 16px; }
p { color: #5b6577; }
}
Блоки @scope можно вкладывать друг в друга — внутренний корень ищется уже внутри внешнего:
@scope (.dashboard) {
p { font-size: 15px; }
@scope (.panel-compact) {
p { font-size: 13px; } /* только в компактных панелях дашборда */
}
}
Не стоит брать @scope, если правило и так короткое и однозначное: .panel-title не станет понятнее, если завернуть его в блок. И помните про поддержку: браузер, который не знает @scope, выбросит весь блок целиком. Критичные для вёрстки стили в такой блок без запасного варианта лучше не прятать.
Бонус для JavaScript: :scope в querySelectorAll
Псевдокласс :scope старше самого at-правила и давно работает в DOM-методах. В querySelectorAll(), matches() и closest() он означает элемент, на котором вызван метод. Это единственный способ выбрать только прямых детей: селектор, начинающийся с >, в JavaScript выбросит ошибку.
const list = document.querySelector('.menu');
list.querySelectorAll('li'); // все пункты, включая вложенные подменю
list.querySelectorAll(':scope > li'); // только пункты верхнего уровня
list.querySelectorAll('> li'); // SyntaxError
Вне всяких блоков :scope в CSS указывает на корень документа, то есть на <html>, так же как :root.
Поддержка браузерами
@scope работает во всех основных браузерах: последним его добавил Firefox 146, и фича получила статус Baseline. Перед тем как убирать старые обходные пути, проверьте, какая доля ваших посетителей сидит на Firefox старше этой версии.
Итог
- @scope (корень) to (граница) ограничивает селекторы частью дерева. Граница находится на любой глубине и в область не входит, поэтому слоты остаются нетронутыми.
- Наследование граница не останавливает: в бублике красим конечные элементы, а не обёртки, и не пишем *.
- Корень выбирается через :scope, который весит как класс. Голые селекторы и & веса не добавляют.
- При равной специфичности побеждает ближайший корень, а не последнее правило в файле. Это закрывает проблемы с вложенными темами и порядком подключения CSS.
- @scope без селекторов в <style> берёт корнем родителя тега, и это удобно для уникальных блоков.
Это правило дополняет остальные инструменты управления каскадом, о которых шла речь в статье про at-правила CSS: @layer решает, какой набор стилей важнее, а @scope — куда эти стили дотягиваются.
Комментарии (0)