Навигация

Каскад, специфичность и современные селекторы

CSS расшифровывается как Cascading Style Sheets, и именно каскад — сердце всей системы. Понимание cascade нужно не для заучивания формулы специфичности, а чтобы предсказывать, какое правило победит в неочевидных ситуациях: при подключении сторонней библиотеки, при переопределениях через !important, при использовании CSS-Layer’ов и при работе с современными селекторами вроде :is(), :where() и :has(). Современные псевдоклассы делают CSS выразительнее, но добавляют нюансы к расчёту веса селектора — их нужно понимать, чтобы каскад оставался предсказуемым.

Содержание

  1. Глубокий разбор
  2. Современные селекторы
  3. Практические примеры
  4. Типичные ошибки и антипаттерны
  5. Ключевые тезисы для интервью
  6. Заключение
  7. Полезные ссылки

Глубокий разбор

Что такое cascade

Cascade (каскад) — это алгоритм, который браузер использует для разрешения конфликтов между декларациями, претендующими на один и тот же элемент и свойство. Он работает в четыре этапа:

  1. Origin и importance — откуда пришла декларация и помечена ли она !important.
  2. Specificity — насколько селектор точно нацелен на элемент.
  3. Order of appearance — какая декларация появилась позже в потоке.
  4. Layer order (с @layer) — в каком слое находится декларация.

Современный алгоритм можно записать так: сначала сравниваются origin + !important, затем layer, затем specificity, затем порядок. Если более ранний критерий различается, более поздние не проверяются.

Origin: откуда берутся стили

Браузер собирает стили из трёх основных источников:

  • User agent stylesheet — стили браузера по умолчанию. Это то, почему <h1> крупный, а <a> синий.
  • User stylesheet — стили, заданные пользователем в настройках браузера или через расширения.
  • Author stylesheet — стили разработчика: внешние файлы, <style>, inline-стили.

Кроме того, каждый источник делится на обычный и !important:

1. User agent normal
2. User normal
3. Author normal
4. Animations
5. Author !important
6. User !important
7. Transitions

Обратите внимание: !important в author-стилях проигрывает !important в user-стилях. Это сделано специально: пользователь должен иметь возможность переопределять авторский дизайн ради доступности. Animations и transitions живут в особых слоях, чтобы анимации могли временно переопределять обычные стили.

CSS Layers: @layer

@layer позволяет явно управлять порядком каскада, не привязываясь к specificity. Слои объявляются один раз, и все правила внутри них считаются менее приоритетными, чем правила в слоях, объявленных позже.

@layer reset, base, components, utilities;

@layer reset {
  body { margin: 0; }
}

@layer components {
  .button { background: blue; }
}

@layer utilities {
  .bg-red { background: red; }
}

Даже если .button имеет specificity (0,1,0), а .bg-red — (0,1,0), .bg-red победит, потому что слой utilities объявлен после components.

Важные нюансы:

  • Необъявленные правила (вне @layer) имеют наивысший приоритет среди author-стилей.
  • @layer влияет только на author-стили; он не может переопределить !important из user-стилей.
  • Вложенные @layer работают: @layer components.buttons { ... }.

Specificity

Specificity (специфичность) — это вес селектора. В современных браузерах она записывается как тройка (A, B, C):

  • A — количество ID-селекторов.
  • B — количество селекторов классов, псевдоклассов и атрибутов.
  • C — количество селекторов типа и псевдоэлементов.

:where() и служебные псевдоклассы внутри него не добавляют веса. :is(), :not() и :has() принимают вес наиболее специфичного аргумента.

#nav .link { }           /* (1,1,0) */
nav ul li a { }          /* (0,0,4) */
.menu > .item:hover { }  /* (0,2,0) */
:where(.menu) a { }      /* (0,0,1) — :where обнуляет вес */
:is(#nav, a) { }         /* (1,0,0) — вес самого специфичного аргумента */

Inline-стили имеют специфичность (1,0,0,0) — выше, чем любой селектор, но ниже !important.

Inheritance

Inheritance (наследование) — это механизм, при котором значения некоторых свойств передаются от родителя к потомкам. Наследуются в основном типографские и цветовые свойства: color, font-family, font-size, line-height, text-align, visibility и другие.

body {
  color: #1f2937;
  font-family: system-ui, sans-serif;
}

/* color и font-family унаследуются всеми вложенными элементами */

Ключевые слова:

  • inherit — явно наследовать значение от родителя.
  • initial — сбросить к начальному значению свойства.
  • unset — если свойство наследуется, вести себя как inherit; иначе как initial.
  • revert — вернуть значение, которое было бы у элемента, если бы текущая декларация не применялась (учитывает origin и cascade).
button {
  all: unset;       /* сбрасывает все свойства */
  display: inline-block;
}

!important

!important поднимает декларацию в отдельный origin-уровень. Он побеждает любую обычную декларацию независимо от specificity, но уступает !important пользовательских стилей и animation/transition-уровням.

!important нарушает нормальный каскад и делает переопределения сложными. Его стоит использовать только тогда, когда это действительно необходимо:

  • утилитарные классы вроде .hidden { display: none !important; };
  • переопределение inline-стилей, установленных сторонними библиотеками;
  • accessibility-переопределения, которые пользователь должен иметь возможность изменить.

Современные селекторы

:is() — логическое ИЛИ с сохранением специфичности

:is() (раньше :matches()) принимает список селекторов и применяется, если элемент соответствует хотя бы одному из них. Он сокращает повторяющиеся цепочки и делает код читаемее.

/* до */
article h1,
article h2,
article h3 {
  color: #111827;
}

/* после */
article :is(h1, h2, h3) {
  color: #111827;
}

Специфичность :is() равна специфичности наиболее специфичного аргумента внутри него:

:is(#header, .link) { }  /* specificity (1,0,0), потому что #header */
:is(.link, a) { }        /* specificity (0,1,0), потому что .link */

Это важно понимать: даже если вы используете :is() с безобидными классами, один ID внутри резко повышает вес всего правила.

:where() — логическое ИЛИ с нулевой специфичностью

:where() работает так же, как :is(), но его специфичность всегда равна нулю (0,0,0). Это делает его идеальным для библиотек, reset-стилей и базовых селекторов, которые должны легко переопределяться.

:where(h1, h2, h3) {
  margin-block: 1.5em 0.5em;
}

/* переопределяется даже одним типовым селектором */
h1 {
  margin-block: 2rem 1rem;
}

:where() полезен, когда нужна группировка без повышения веса. Например, сброс отступов у заголовков через :where(h1, h2, h3) не мешает компонентным стилям.

:not() — отрицание с весом аргумента

:not() выбирает элементы, которые не соответствуют переданному селектору. Современный :not() принимает несколько аргументов.

/* элементы списка, кроме первого и последнего */
li:not(:first-child, :last-child) {
  color: #6b7280;
}

Специфичность :not() равна специфичности наиболее специфичного аргумента — так же, как у :is():

button:not(.primary) { }      /* specificity (0,1,1) */
button:not(#submit) { }       /* specificity (1,0,1) */

:has() — «родительский селектор»

:has() позволяет выбрать элемент на основе его потомков. Это долгожданный способ стилизовать родителя по содержимому без изменения HTML.

/* карточка, внутри которой есть изображение */
.card:has(img) {
  display: grid;
  grid-template-columns: 200px 1fr;
}

/* поле ввода с ошибкой */
.field:has(input:invalid) .error-message {
  display: block;
}

Специфичность :has() равна специфичности самого специфичного селектора внутри скобок плюс вес самого :has() как псевдокласса. Например, .card:has(img) имеет specificity (0,1,1) — класс .card (0,1,0) плюс :has(img) (0,0,1).

Важное ограничение: :has() не может быть вложен сам в себя и не работает внутри псевдоэлементов (::after:has(...) невалидно). Кроме того, он потенциально дорог в вычислении, поэтому браузеры накладывают ограничения на сложные комбинации.

CSS Nesting

Nesting (вложенность) позволяет писать правила внутри других правил, как в препроцессорах, но нативно в CSS.

.card {
  background: white;

  &:hover {
    background: #f3f4f6;
  }

  .title {
    font-size: 1.25rem;
  }
}

Ключевые правила:

  • Вложенный селектор должен начинаться с комбинатора (&, >, +, ~) или символа :, [, ., #. Иначе браузер интерпретирует его как type selector, и это может сломать селектор.
  • & — это parent selector; он заменяется на родительский селектор.
  • Специфичность вложенного правила считается по итоговому развёрнутому селектору.
.card {
  & .title { }   /* равносильно .card .title */
  > .title { }   /* равносильно .card > .title */
  .title & { }   /* равносильно .title .card */
}

Nesting не заменяет методологии именования, но уменьшает повторение родительского класса в компонентных стилях.

Логические свойства

Logical properties — это свойства, ориентированные на направление потока документа, а не на физические стороны. Вместо left/right/top/bottom используются inline-start/inline-end/block-start/block-end.

  • inline axis — ось текста (для LTR это горизонталь, слева направо).
  • block axis — ось блоков (вертикаль, сверху вниз для горизонтального письма).
/* физические свойства */
.dialog {
  width: 300px;
  margin-left: auto;
  margin-right: auto;
  border-left: 4px solid blue;
  border-right: 1px solid gray;
}

/* логические свойства */
.dialog {
  inline-size: 300px;
  margin-inline: auto;
  border-inline-start: 4px solid blue;
  border-inline-end: 1px solid gray;
}

Логические свойства упрощают адаптацию под RTL (right-to-left) и вертикальное письмо: достаточно сменить writing-mode или direction, не переписывая стили.

Частые пары замен:

ФизическоеЛогическое
widthinline-size
heightblock-size
margin-left / margin-rightmargin-inline-start / margin-inline-end или margin-inline
padding-top / padding-bottompadding-block-start / padding-block-end или padding-block
border-leftborder-inline-start
text-align: lefttext-align: start

Практические примеры

Пример 1: @layer против specificity

@layer base, overrides;

@layer base {
  #header .nav-link {
    color: blue; /* specificity (1,1,0) */
  }
}

@layer overrides {
  .nav-link.active {
    color: red; /* specificity (0,2,0) */
  }
}
<header id="header">
  <a class="nav-link active">Link</a>
</header>

Несмотря на то что #header .nav-link специфичнее, .nav-link.active покрасит ссылку в красный, потому что слой overrides объявлен после base.

Пример 2: !important и inline-стили

<p id="text" style="color: green;">Text</p>
#text {
  color: red !important;
}

Текст будет красным: !important в author-CSS побеждает inline-стили. Но если у пользователя в настройках браузера задано p { color: black !important; }, текст станет чёрным.

Пример 3: наследование и currentColor

.icon-button {
  color: #2563eb;
  border: 2px solid currentColor;
}

.icon-button svg {
  fill: currentColor;
}
<button class="icon-button">
  <svg>...</svg>
  Save
</button>

currentColor — это ключевое слово, равное вычисленному значению color у текущего элемента. Граница и SVG-иконка унаследуют цвет текста кнопки. Это удобный способ строить темизируемые компоненты без лишних переменных.

Пример 4: revert vs unset

button.custom {
  all: unset;       /* button теряет display: inline-block, font, padding */
}

button.revert {
  all: revert;      /* возвращается к user agent стилям button */
}

unset сбрасывает всё к начальным или наследуемым значениям, игнорируя то, что было у <button> по умолчанию. revert возвращает стили, которые браузер назначает <button> изначально.

Пример 5: :has() для формы

<div class="field">
  <label for="email">Email</label>
  <input id="email" type="email" required>
  <span class="error">Неверный формат</span>
</div>
.field:has(input:invalid:not(:placeholder-shown)) .error {
  display: block;
  color: #dc2626;
}

.error {
  display: none;
}

Сообщение об ошибке показывается только после того, как пользователь что-то ввёл и значение невалидно.

Пример 6: nesting для компонента

.card {
  padding: 1rem;
  border-radius: 0.5rem;
  background: white;

  &:hover {
    box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
  }

  & > .title {
    font-size: 1.25rem;
    margin-block-end: 0.5rem;
  }

  & .actions {
    display: flex;
    gap: 0.5rem;
  }
}

Пример 7: логические свойства в RTL-раскладке

.sidebar {
  inline-size: 240px;
  padding-inline: 1rem;
  border-inline-end: 1px solid #e5e7eb;
}

При direction: rtl border-inline-end автоматически окажется слева, а контент прижмётся к правому краю. Никаких дополнительных правил не нужно.

Типичные ошибки и антипаттерны

  • Борьба со специфичностью через ещё большую специфичность. Вместо #app .header .nav .link используйте @layer или более плоские селекторы.
  • !important ради быстрого фикса. Каждый !important — это долг: потом его сложнее переопределить, чем обычное правило.
  • Игнорирование порядка @layer. Если слои не объявлены в начале файла, порядок их первого появления определяет приоритет, что легко запутать.
  • Путаница initial и unset. initial всегда сбрасывает к дефолту свойства, даже если оно наследуемое. unset учитывает наследование.
  • Использование all: unset на семантических элементах. Кнопка после all: unset перестаёт быть доступной с клавиатуры. Для сброса используйте all: revert или целевые свойства.
  • Неучёт user-стилей и accessibility. Пользовательские стили с !important могут переопределять ваши. Это не баг, а фича браузера.
  • Использование :is() с ID внутри без понимания последствий. Один #header внутри :is() поднимает специфичность всего правила до уровня ID и делает переопределение сложным.
  • Путаница :is() и :where(). :is() сохраняет вес аргументов, :where() — нет. Для reset и базовых стилей почти всегда нужен :where().
  • Чрезмерно сложные :has(). :has() мощный, но дорогой. Не стоит строить на нём всю логику интерфейса; для динамических состояний часто лучше класс через JS.
  • Nesting без & в начале. .title { ... } внутри .card { ... } может не сработать, если браузер интерпретирует .title как type selector. Явно используйте & .title.
  • Смешение логических и физических свойств в одном проекте. Использование margin-left и margin-inline-start одновременно приводит к конфликтам в RTL.

Ключевые тезисы для интервью

  • Cascade разрешает конфликты в порядке: origin/importance → layer → specificity (A, B, C) → порядок появления. Origin-уровни: user agent < user < author; !important инвертирует порядок внутри своего уровня.
  • @layer управляет приоритетом независимо от specificity: позже объявленный слой побеждает; правила вне @layer имеют приоритет выше любых слоёв.
  • Specificity — это (A, B, C): ID → классы/атрибуты/псевдоклассы → типы/псевдоэлементы. :where() обнуляет вклад, :is() и :not() берут максимальный вес аргумента, :has() складывает вес своего селектора с весом аргумента.
  • Наследование передаёт типографику, цвет и подобные свойства потомкам; ключевые слова inherit, initial, unset, revert дают точный контроль без переписывания стилей, но all: unset на интерактивных элементах ломает доступность — для сброса лучше all: revert.
  • !important — инструмент для утилитарных классов и accessibility-переопределений, а не для повседневной борьбы со специфичностью.
  • :has() стилизует родителя по содержимому; не вкладывается сам в себя и не работает в псевдоэлементах. CSS Nesting нативен, но требует & или комбинатора в начале вложенного селектора; специфичность считается по развёрнутому селектору.
  • Logical properties (inline-size, margin-inline, border-block-end) ориентированы на поток текста, а не на физические стороны — упрощают RTL и вертикальное письмо.

Заключение

Cascade — это предсказуемая иерархия приоритетов: origin и importance, затем layer, специфичность и порядок появления. Понимание этой системы позволяет избегать борьбы со специфичностью через !important или искусственно длинные селекторы. @layer — современный инструмент для управления приоритетами в больших проектах. Современные селекторы :is(), :where(), :has() и nesting делают CSS выразительнее, но их специфичность нужно учитывать, чтобы каскад оставался предсказуемым. Logical properties упрощают поддержку RTL и вертикального письма без лишних медиа-запросов.

Полезные ссылки