Каскад, специфичность и современные селекторы
CSS расшифровывается как Cascading Style Sheets, и именно каскад — сердце всей системы. Понимание cascade нужно не для заучивания формулы специфичности, а чтобы предсказывать, какое правило победит в неочевидных ситуациях: при подключении сторонней библиотеки, при переопределениях через !important, при использовании CSS-Layer’ов и при работе с современными селекторами вроде :is(), :where() и :has(). Современные псевдоклассы делают CSS выразительнее, но добавляют нюансы к расчёту веса селектора — их нужно понимать, чтобы каскад оставался предсказуемым.
Содержание
- Глубокий разбор
- Современные селекторы
- Практические примеры
- Типичные ошибки и антипаттерны
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Глубокий разбор
Что такое cascade
Cascade (каскад) — это алгоритм, который браузер использует для разрешения конфликтов между декларациями, претендующими на один и тот же элемент и свойство. Он работает в четыре этапа:
- Origin и importance — откуда пришла декларация и помечена ли она
!important. - Specificity — насколько селектор точно нацелен на элемент.
- Order of appearance — какая декларация появилась позже в потоке.
- 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, не переписывая стили.
Частые пары замен:
| Физическое | Логическое |
|---|---|
width | inline-size |
height | block-size |
margin-left / margin-right | margin-inline-start / margin-inline-end или margin-inline |
padding-top / padding-bottom | padding-block-start / padding-block-end или padding-block |
border-left | border-inline-start |
text-align: left | text-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 и вертикального письма без лишних медиа-запросов.