Представим панель на дашборде: у неё есть шапка с вкладками, подписи, ссылки — и пустое место посередине, куда подставляется чужой виджет. Это может быть прогноз погоды, график продаж или карточка пользователя. Такое место называют слотом: панель отвечает за рамку вокруг, а содержимое слота живёт своей жизнью и пишет собственные стили.

Проблема всплывает, как только панели нужен свой цвет текста. Пишем .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 старше этой версии.

Поддержка браузерами
chrome
Chrome
118
firefox
Firefox
146
edge
Edge
118
safari
Safari
17.4
opera
Opera
106

Итог

  • @scope (корень) to (граница) ограничивает селекторы частью дерева. Граница находится на любой глубине и в область не входит, поэтому слоты остаются нетронутыми.
  • Наследование граница не останавливает: в бублике красим конечные элементы, а не обёртки, и не пишем *.
  • Корень выбирается через :scope, который весит как класс. Голые селекторы и & веса не добавляют.
  • При равной специфичности побеждает ближайший корень, а не последнее правило в файле. Это закрывает проблемы с вложенными темами и порядком подключения CSS.
  • @scope без селекторов в <style> берёт корнем родителя тега, и это удобно для уникальных блоков.

Это правило дополняет остальные инструменты управления каскадом, о которых шла речь в статье про at-правила CSS: @layer решает, какой набор стилей важнее, а @scope — куда эти стили дотягиваются.