CSS с нуля: каскад, специфичность, наследование, единицы измерения
Типичная траектория знакомства с CSS: «тут же всего три свойства» → «почему оно не применяется?!» →
!important → «почему теперь вообще ничего не применяется?!». Причина в том, что CSS учат как
справочник свойств, а устроен он как система разрешения конфликтов. Свойств сотни, держать их
в голове не нужно — есть MDN. А правил
разрешения конфликтов и стадий вычисления значения всего несколько, и они объясняют почти всю «магию».
Эта статья — про фундамент: как объявление превращается в число на экране. Раскладку (flexbox, grid, позиционирование) разбирает Вёрстка, организацию файлов и методологии — Архитектура стилей; здесь механика, на которой стоит и то и другое.
1. CSS — декларативный решатель ограничений
В Canvas вы рисуете императивно: поставь курсор сюда, залей прямоугольник. В CSS вы не рисуете — вы объявляете ограничения: «у заголовков размер 1.5em», «карточка не шире 40rem», «между элементами 1rem». Движок собирает из всех объявлений систему и решает её для конкретного окна, шрифта, языка и настроек пользователя. Отсюда два следствия.
- Конфликты — норма, а не ошибка. Стили браузера, пользователя, дизайн-системы, библиотеки
компонентов и страницы одновременно говорят про
font-size. Каскад — детерминированный алгоритм выбора победителя; без него веб не работал бы вовсе. - Вы почти никогда не задаёте пиксели. Вы задаёте отношения (
em,%,fr,clamp()), пиксели вычисляет браузер. Чем меньше «магических чисел», тем лучше макет переживает чужой шрифт, длинное немецкое слово и зум 200%.
Как из HTML и CSS вообще получается картинка — в статье Как работает браузер; здесь только этап «вычислить стили».
2. Конвейер значения: шесть стадий одного числа
У свойства не одно значение, а цепочка. CSS Cascade 5 называет стадии так:
- Declared — все объявления, чей селектор совпал с элементом, из всех источников; Cascaded — победитель по правилам каскада (раздел 3), максимум один на свойство.
- Specified — если победителя нет: наследуемое свойство берёт значение родителя, ненаследуемое —
initialиз спецификации. - Computed — абсолютизация без знания раскладки:
1.5em→24px,bold→700, относительный URL → абсолютный. Именно computed-значение наследуется детям, поэтому размеры не растут экспоненциально по дереву. - Used — то, что считается только после layout (
width: 50%→384px,flex: 1→ доля места), а Actual — округление под пиксельную сетку:33.333pxпри DPR 2 станет33.5px.
getComputedStyle() возвращает resolved value: computed для большинства свойств, used для геометрии.
const cs = getComputedStyle(document.querySelector('.card'));
cs.fontSize; // "24px" — computed, em уже раскрыт
cs.width; // "384px" — used, проценты уже посчитаны
cs.getPropertyValue('--gap'); // " 1rem" — кастомные свойства НЕ абсолютизируются
3. Каскад: кто побеждает и в каком порядке
Каскад сортирует конфликтующие объявления по цепочке критериев; следующий критерий смотрят только при ничьей на предыдущем.
Полный порядок сортировки (CSS Cascade Level 5, §6.10):
- Происхождение и важность:
transition→!importantбраузера →!importantпользователя →!importantавтора →@keyframes→ обычные автора → обычные пользователя → обычные браузера. - Контекст инкапсуляции. Обычные объявления из shadow-дерева сильнее внешних; для
!important— наоборот, внешний документ побеждает. - Инлайновый
style— сильнее любого обычного правила того же происхождения. - Слой (
@layer). Позже объявленный слой сильнее; правила вне слоёв сильнее всех слоёв. - Специфичность (раздел 4).
- Порядок в исходнике — кто ниже, тот и прав.
Обратите внимание: слой сравнивается раньше специфичности. Правило .bg-brand из слоя utilities
победит #page .sidebar a.btn из components, хотя у второго специфичность выше на порядок. В этом и
ценность слоёв — они выключают гонку специфичности.
/* Порядок объявления слоёв задаёт приоритет — один раз, в самом верху бандла */
@layer reset, base, components, utilities;
@layer components {
#page .sidebar a.btn { background: #1f2937; } /* 0,3,1 — но слой младше */
}
@layer utilities {
.bg-brand { background: #2563eb; } /* 0,1,0 — и всё равно побеждает */
}
/* Вне слоёв — сильнее любого слоя, поэтому сюда ничего не кладут «случайно» */
.debug-outline { outline: 2px solid magenta; }
Тонкости: !important переворачивает порядок слоёв (важное из reset победит важное из
utilities) и делает незаслоённые стили слабее заслоённых; слой можно объявить пустым и наполнять по
частям в любом порядке импорта — @import url(vendor.css) layer(vendor);. Допустимые применения
!important: пользовательские таблицы стилей и расширения; utility-классы, если слоёв нет;
переопределение виджета, который пишет инлайн; временный дебаг. Всё остальное — симптом непостроенной
архитектуры: как только !important появляется с обеих сторон, гонка переезжает этажом выше.
4. Специфичность: вектор из трёх разрядов
Специфичность селектора — тройка (A, B, C), которая сравнивается лексикографически, как версии
пакетов. Переносов между разрядами нет: одиннадцать классов не догоняют один id.
| Составляющая селектора | Разряд | Пример |
|---|---|---|
#id |
A | #header → 1,0,0 |
.class, [attr], :hover, :focus-visible |
B | .card[data-open]:focus → 0,3,0 |
тег, ::before, ::first-line |
C | ul li::marker → 0,0,3 |
*, >, +, ~, пробел, :where() |
— | всегда 0 |
Что забывают чаще всего:
:is(),:has(),:not()берут специфичность самого сильного аргумента::not(#x)— это 1,0,0, хотя визуально там «отрицание».:where()всегда 0,0,0 — официальный инструмент «отдать стиль без боя», идеален для базовых стилей и библиотек: прикладной код переопределит одним классом.- Нативная вложенность
&ведёт себя как:is(...)от родительских селекторов, то есть вложенность специфичность повышает — ровно та же ловушка, что когда-то в Sass. [id="header"]весит как класс (0,1,0), а не какid— иногда это удобный способ снизить вес. Инлайновыйstyleв вектор вообще не входит: он выигрывает на более раннем шаге сортировки.
@layer base {
/* Библиотечный приём: базовые стили с нулевой специфичностью */
:where(a[href]) { color: var(--link, #2563eb); text-underline-offset: 0.15em; }
:where(ul, ol)[class] { list-style: none; padding-inline-start: 0; }
}
.footer a { color: currentColor; } /* перебивается самым простым селектором */
Обратный приём — «удвоение класса» .btn.btn (0,2,0) вместо !important — костыль, но иногда
единственный способ перебить чужую библиотеку. Здоровая же стратегия: держать специфичность плоской
(один класс на компонент), а конфликты разруливать слоями. Про методологии — в
Архитектуре стилей.
5. Как браузер это считает
Представляйте себе не «поиск по селекторам», а инвертированный индекс: правила сгруппированы по правому крайнему компоненту селектора (id, класс, тег), и для элемента достаются только подходящие корзины. Сопоставление идёт справа налево — так дешевле отбрасывать кандидатов.
id, класс, тег, атрибут P->>S: элемент div class="card" готов S->>O: дай кандидатов для .card и div O-->>S: 7 объявлений-кандидатов Note over S: каскад: происхождение → слой →
специфичность → порядок; затем наследование S-->>L: computed style L-->>S: used values после раскладки
- Сложность селектора почти никогда не бутылочное горлышко. Дороже — объём инвалидации: сколько элементов придётся пересчитать после изменения.
- Смена класса на
<body>или переменной на:rootинвалидирует весь документ; смена класса на карточке — только её поддерево. Это разница между 40 мс и 0.4 мс на recalc style. По той же причине:has()мощный, но опасный:body:has(.modal-open)стоит заметно,.card:has(> img)— почти ничего.
Измерять: DevTools → Performance с галочкой Selector stats, запись взаимодействия, блок
Recalculate Style → вкладка Selector Stats — видно, сколько раз каждый селектор матчился и сколько
это заняло. Неиспользуемый CSS — панель Coverage (Ctrl+Shift+P → Coverage).
6. Наследование: что течёт вниз по дереву
Наследуются в основном типографские и «текстовые» свойства — там, где наследование интуитивно.
| Наследуются | Не наследуются |
|---|---|
color, font-*, line-height, letter-spacing |
background-*, border-*, box-shadow |
text-align, text-indent, white-space, word-break |
display, position, width, height |
visibility, cursor, direction, list-style-* |
margin, padding, overflow, z-index |
hyphens, quotes, text-transform, caret-color |
transform, opacity, filter, contain |
Ключевая тонкость: унаследованное значение слабее любого объявления, включая стиль браузера. Отсюда вечная пара сюрпризов:
.article { color: #333; } /* у a есть UA-объявление color, наследование не сработает */
.article a { color: inherit; } /* объявление бьёт UA-объявление */
button, input, select, textarea { font: inherit; color: inherit; } /* формы — то же самое */
/* Пять универсальных значений, которые есть у любого свойства */
.x { color: inherit; } /* значение родителя, даже для ненаследуемых свойств */
.x { color: initial; } /* initial из СПЕЦИФИКАЦИИ, а не «как было у браузера» */
.x { color: unset; } /* inherit для наследуемых, initial для остальных */
.x { color: revert; } /* откат к предыдущему происхождению: пользователь/браузер */
.x { color: revert-layer; } /* откат к значению из предыдущего слоя */
.link-button { all: unset; cursor: pointer; color: var(--link); } /* полный сброс кнопки */
Разница initial и revert — частая путаница: display: initial даст inline (initial-значение по
спецификации), а не block для <div>; «как у браузера» возвращает только display: revert. А
revert-layer незаменим при интеграции чужой дизайн-системы: в своём слое точечно отменяете
собственное переопределение и падаете обратно на вендорское.
7. Кастомные свойства: не переменные препроцессора
--foo — полноценное CSS-свойство с произвольным именем и типом «поток токенов»; отсюда особенности.
:root {
--space: 1rem;
--brand: oklch(0.6 0.18 255);
--surface: light-dark(#ffffff, #16181d); /* требует color-scheme: light dark */
}
.card {
padding: var(--space);
gap: calc(var(--space) / 2); /* производная считается в момент подстановки */
border-radius: var(--radius, 8px); /* fallback после запятой, может быть любым */
}
- Наследуются. Переопределив
--spaceна.compact, вы поменяете отступы во всём поддереве — это делает их идеальным механизмом тем и вариантов компонента. - Ленивые. Значение хранится как токены и разбирается только при подстановке:
--x: 10 pxспокойно доживёт до продакшена и рванёт при использовании. - Ошибка подстановки = IACVT (invalid at computed-value time): свойство становится унаследованным
или
initial, а не откатывается к предыдущему объявлению. Классическая ловушка:
.card { padding: 12px; } /* «запасной вариант», который не сработает */
.card { --pad: 12; padding: var(--pad); } /* итог: padding = 0, а не 12px */
- Нельзя использовать в условиях
@media, в именах свойств и селекторах — только в значениях; для условий по значению переменной есть@container style(--theme: dark). @propertyдаёт им тип: значение валидируется, есть initial-value, можно запретить наследование и — главное — анимировать:
@property --angle { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
.spinner {
background: conic-gradient(from var(--angle), #2563eb, transparent);
transition: --angle 400ms linear; /* без @property анимация невозможна */
}
.spinner:hover { --angle: 360deg; }
Про цену: изменение переменной на :root пересчитывает стиль всем, кто её читает. Правило —
объявляйте её на самом узком элементе, которому она нужна, а частые обновления (drag, scroll)
пишите из JS в конкретный узел:
document.documentElement.style.setProperty('--x', `${e.clientX}px`); // плохо: весь документ
card.style.setProperty('--x', `${e.clientX - rect.left}px`); // хорошо: только карточка
8. Единицы измерения: во что верить
Единиц много, но выбор сводится к вопросу «от чего это должно зависеть».
Абсолютные. px в CSS — не физический пиксель, а эталонный: угловая единица, привязанная к 1/96
дюйма на расстоянии вытянутой руки. Соотношения зафиксированы (1in = 96px, 1pt = 1/72in), а на
устройстве один CSS-пиксель занимает 2, 3 или 2.75 физических — это devicePixelRatio. Единицы cm,
mm, pt осмысленны только в @media print.
Шрифтовые.
em— размер шрифта самого элемента, но внутриfont-sizeберётся размер родителя. Отсюда компаундирование: вложенные списки сfont-size: 0.9emуменьшаются по экспоненте.rem— размер шрифта корня, то есть по умолчанию настройка пользователя (обычно 16px, у слабовидящих 20–24px). Не пишитеhtml { font-size: 16px }— это молча ломает настройку. Приёмhtml { font-size: 62.5% }пропорциональность сохраняет, но требует, чтобы дальше всё было вrem.ch— ширина продвижения символа «0»,cap— высота заглавной,ex— высота строчной «x»,ic— ширина CJK-глифа;lh/rlh— высота строки элемента и корня.max-width: 68chдержит комфортную длину строки при любой гарнитуре,lhвыравнивает иконки по ритму текста без магических чисел.
Вьюпортные: vw, vh, vmin, vmax, логические vi/vb. Две ловушки. Первая: 100vw
включает ширину скроллбара на десктопе — отсюда классический горизонтальный скролл, безопаснее
100% или 100dvw. Вторая: 100vh на мобильных считался от «большого» вьюпорта без адресной строки;
сейчас есть svh (панели видны), lvh (панели скрыты) и dvh (меняется на лету) — для «экран
целиком» берите 100dvh, для гарантированно видимой области 100svh.
Проценты считаются от разного, и это надо знать наизусть:
| Свойство | База процента |
|---|---|
width, margin-*, padding-* (включая -top/-bottom!) |
ширина содержащего блока |
height |
высота содержащего блока, и только если она определена |
line-height |
собственный font-size; вычисляется сразу и наследуется как длина |
transform: translate() |
размер самого элемента |
background-position |
разница размеров контейнера и картинки |
Из-за строки про padding работал старый трюк с соотношением сторон (padding-top: 56.25%) — сегодня
это aspect-ratio: 16 / 9. Из-за строки про line-height действует твёрдое правило: задавайте его
без единиц — 1.5 наследуется как коэффициент и пересчитывается от шрифта ребёнка, а 150% —
как готовые пиксели, и это ломает крупные заголовки.
Функции calc(), min(), max(), clamp() превращают единицы в выражения:
.layout { width: calc(100% - 2rem); } /* вокруг + и - обязательны пробелы */
.container {
width: min(72rem, 90%); /* не шире 72rem, но с полями 5% */
padding-inline: max(1rem, 4vw); /* нижняя граница отступа */
}
/* флюидная шкала: минимум, предпочтительное выражение, максимум */
:root { --step-3: clamp(2rem, 1.4rem + 3vw, 3.5rem); }
h1 { font-size: var(--step-3); }
Критично для доступности: в среднем слагаемом clamp() обязана быть rem-часть. Чистый
font-size: 4vw не реагирует ни на зум, ни на настройку размера шрифта — прямое нарушение WCAG 1.4.4
(текст масштабируется до 200%). Согласованную шкалу удобно сгенерировать на
utopia.fyi. И заведите привычку к логическим свойствам
(margin-inline, padding-block) — они сами переворачиваются для RTL-языков.
9. Почему «тормозит» из-за CSS и как это измерить
CSS влияет на все три Core Web Vitals — и обычно не там, где ищут.
LCP. Внешний CSS блокирует рендер: пока таблица не загружена и не разобрана, дерева отрисовки нет.
Лишний @import внутри CSS добавляет полный round-trip, потому что про вложенный файл браузер узнаёт
только после разбора внешнего. Лечение — инлайнить критическое и грузить остальное неблокирующе:
<style>/* критический CSS первого экрана, ~10–14 КБ */</style>
<link rel="stylesheet" href="/assets/rest.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/assets/rest.css"></noscript>
CLS. Сдвиги дают картинки без width/height или aspect-ratio, веб-шрифты с метриками, не
совпадающими с фолбэком, и баннеры, вставляемые в поток:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
size-adjust: 107%; /* подгон под метрики системного фолбэка */
ascent-override: 90%;
}
.hero { aspect-ratio: 16 / 9; }
INP. Медленный отклик часто упирается не в JS, а в recalc style и layout после смены класса на
корне. Помогают узкая зона инвалидации (раздел 5), contain: layout style на независимых блоках и
content-visibility: auto для длинных списков за пределами экрана. Порядок измерения:
npx vite build && ls -lh dist/assets/*.css # бюджет: сколько CSS уезжает пользователю
npx lighthouse https://example.com --only-categories=performance --view # render-blocking
npm i web-vitals # полевые данные с реальных пользователей
import { onLCP, onCLS, onINP } from 'web-vitals';
// attribution подскажет виновника: для LCP — элемент, для CLS — источник сдвига
const send = (m) => navigator.sendBeacon('/rum', JSON.stringify({ name: m.name, value: m.value }));
onLCP(send); onCLS(send); onINP(send);
Дальше — DevTools → Performance → полосы Recalculate Style и Layout: если recalc style
стабильно больше 5–8 мс на взаимодействие, ищите широкие селекторы, переменные на :root и классы на
<body>. Бюджеты — в Производительности фронтенда, попадание
CSS в бандл и чанки — в Сборке фронтенда.
10. Отладка: «почему мой стиль не применился»
Девяносто процентов вопросов закрываются одним проходом по дереву решений.
в панели Styles?"} B -- "Нет" --> C{"Селектор совпадает?"} C -- "Нет" --> C1["Опечатка, регистр,
элемент в shadow DOM,
файл не загрузился"] C -- "Да" --> C2["Отброшено парсером:
невалидное значение или
незакрытая скобка выше"] B -- "Да, перечёркнуто" --> D["Проиграло каскад: слой →
специфичность → порядок.
Лечение: :where и слои"] B -- "Да, не перечёркнуто" --> E{"Эффект есть?"} E -- "Нет" --> E1["Свойство неприменимо к display:
width у inline, gap без flex,
z-index без position"] E -- "Не тот" --> F["Вкладка Computed: итог
и стрелка к источнику"]
- Styles — перечёркнутое объявление означает проигранный каскад, жёлтый треугольник — невалидное
значение, кнопка
:hovфорсирует:hover/:focus-visible/:active. - Computed с галочкой Show all — итоговые значения, стрелка к правилу-победителю и пометка о наследовании; Elements → Layout — оверлеи grid и flex для вёрстки.
CSS.supports('width', '1cqi'); // поддержка единицы; в CSS то же самое даёт @supports
el.matches('.card:has(> img)'); // совпадает ли селектор с элементом
11. Типичные ошибки
- Гонка специфичности. Каждое исправление добавляет селектор слева, через год —
#app .page .sidebar ul li a.link.is-active. Лечение: слои и один класс на компонент. !importantкак обычный инструмент — работает ровно до второго!important.pxдля шрифта и ритма игнорирует настройку пользователя: текст и отступы — вrem.line-heightс единицами наследуется как длина и ломает заголовки.- Забытый
box-sizing:width: 100%плюсpaddingдаёт переполнение — как100vhна мобильных и100vwсо скроллбаром, только по другой оси. - Компаундирование
em:.menu li { font-size: 0.9em }— и третий уровень уже нечитаем. - Переменные только на
:root— компонент не настраивается, а каждое изменение инвалидирует документ. Рядом стоит расчёт на «фолбэк-объявление» передvar(): IACVT обнуляет свойство. - Стили, привязанные к структуре DOM (
div > div > span) вместо семантики и состояний. Про правильные «крючки» — в Семантическом HTML.
Минимальный современный базовый слой закрывает половину списка:
@layer reset, base, components, utilities;
@layer reset {
*, *::before, *::after { box-sizing: border-box; margin: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-block-size: 100dvh; line-height: 1.5; }
img, picture, video, canvas, svg { display: block; max-inline-size: 100%; }
input, button, textarea, select { font: inherit; color: inherit; }
p, h1, h2, h3, h4 { overflow-wrap: break-word; }
:where(h1, h2, h3, h4) { text-wrap: balance; line-height: 1.2; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important; transition-duration: 0.01ms !important;
}
}
}
Блок prefers-reduced-motion — не косметика: для части пользователей анимации вызывают физическое
недомогание. Эту и другие настройки (prefers-contrast, forced-colors) разбирает трек «Доступность».
12. Мини-итог
- У значения шесть стадий; наследуется computed, а видите вы обычно used.
- Каскад сортирует по цепочке: происхождение и важность → контекст → инлайн → слой →
специфичность → порядок. Слой раньше специфичности — в этом весь смысл
@layer. - Специфичность — вектор (A, B, C), сравнение лексикографическое, переносов между разрядами нет:
:where()обнуляет,:is()/:not()/:has()берут максимум из аргументов. - Унаследованное значение слабее любого объявления, включая стиль браузера — отсюда
font: inherit. Кастомные свойства наследуются, вычисляются лениво и при ошибке дают IACVT, а не откат. - Единицу выбирают по вопросу «от чего это зависит»:
rem— от пользователя,em/ch— от шрифта контекста,dvh/cqi— от контейнера,px— ни от чего. А цена CSS — это размер таблицы стилей и ширина зоны инвалидации, а не «сложные селекторы».
Источники
- CSS Cascading and Inheritance Level 5 — порядок каскада,
слои, стадии значения; Selectors Level 4 —
специфичность; CSS Values and Units 4 — единицы и
calc. - MDN: каскад, специфичность и наследование, web.dev — Learn CSS, Chrome DevTools: CSS и про каскадные слои от команды Chrome.
- Josh Comeau, The Surprising Truth About Pixels and Accessibility; Eric Meyer, Estelle Weyl. CSS: The Definitive Guide (5-е изд.); Lea Verou. CSS Secrets.
Что дальше
Мы разобрали, как получается значение. Следующий шаг — как из значений получается раскладка: почему flex сжимает элементы, откуда берётся свободное место в grid и чем контейнерные запросы лучше медиазапросов в компонентном коде.
Вёрстка: flexbox, grid, позиционирование, адаптивность и контейнерные запросы