Переменные, двумерная сетка, настоящие каскадные слои и нативная вложенность покрывают большую часть того, ради чего раньше ставили Sass или Tailwind. Эта статья — про то, что реально доступно в браузере прямо сейчас, без сборки и без библиотек: как устроены кастомные свойства, когда Grid, а когда Flex, как слои снимают «войну специфичности» и как нативная вложенность заменяет препроцессор.
Дано по состоянию на октябрь 2026. Все разобранное поддерживается актуальными версиями Chromium, Firefox и Safari.
Часть I. Кастомные свойства: переменные, которые живут в DOM
1. Синтаксис и главное отличие от препроцессоров
Кастомное свойство — это имя с двумя дефисами в начале. Задаётся в любом блоке правил, читается функцией var():
:root {
--accent: #4f46e5;
--radius: 12px;
--space-1: 0.25rem;
--space-2: 0.5rem;
}
.card {
border-radius: var(--radius);
border-left: 4px solid var(--accent);
padding: var(--space-2);
/* фолбэк, если --gap нигде не объявлен: */
gap: var(--gap, 8px);
}Препроцессорная переменная ($accent в Sass) — это строка, которая «разворачивается» при сборке: после компиляции браузер её не видит и не может менять. Кастомное свойство, наоборот, — живой объект CSSOM: оно наследуется по дереву DOM (объявили у родителя — дети видят), пересчитывается при смене медиаусловий и класса и читается/ставится из JS. Отсюда все его суперсилы.
2. Наследование и каскад решают, какое значение «дошло» до элемента
section { --gap: 16px; } /* у всех детей --gap = 16px */
.card { gap: var(--gap); } /* возьмёт 16px от ближайшего предка */
.card.wide { --gap: 32px; } /* но сам переопределит для себя и детей */Значение свойства всегда «то, что вычислено для конкретного элемента» с учётом наследования и специфичности. Это же позволяет паттерн «настроил родителя — перестроились дети»: смените --fs на article — и весь вложенный текст пересчитается через calc().
3. Арифметика: calc(), вложенные var() и масштабирование
:root { --unit: 8px; --scale: 1.5; }
.card {
padding: calc(var(--unit) * 2); /* 16px */
font-size: calc(1rem * var(--scale)); /* 24px при scale=1.5 */
/* вычисляем один блок, переиспользуем везде: */
--card-pad: calc(var(--unit) * 3);
gap: var(--card-pad);
}Важная ловушка синтаксиса: вокруг операторов + и - внутри calc() пробел обязателен — без него браузер прочитает минус как знак числа, а не оператор, и всё выражение станет невалидным. calc(var(--a) - var(--b)) — работает; calc(var(--a)-var(--b)) — нет. Для * и / пробелы не обязательны (но и не вредят).
Единицы нельзя умножать как в JS: calc(2 * 10px) корректно, а вот calc(10px * 10px) — ошибка размера.
4. Анимация переменных и @property
По умолчанию кастомные свойства не анимируются «как числа/цвета» — браузер считает их строкой. Чтобы анимировать, нужно объявить тип через @property (это же даёт валидацию значения и значение по умолчанию):
@property --angle {
syntax: "<angle>"; /* теперь браузер знает, что это угол, и умеет его интерполировать */
initial-value: 0deg;
inherits: false;
}
.loader {
--angle: 0deg;
transform: rotate(var(--angle));
animation: spin 2s linear infinite;
}
@keyframes spin { to { --angle: 360deg; } } /* плавный оборот без step-скачка */Без @property такой to { --angle: 360deg } дал бы мгновенный скачок в конце, а не плавное вращение. Это же применяется к «оживлению» градиентных углов, толщины обводки через переменную и т.п.
5. Чтение и запись из JavaScript
const el = document.querySelector('.card');
// Прочитать вычисленное значение
const accent = getComputedStyle(el).getPropertyValue('--accent').trim();
// Изменить для конкретного элемента (и его потомков)
el.style.setProperty('--space-2', '12px');
// Глобально (вся тема сайта):
document.documentElement.style.setProperty('--accent', '#059669');
// Снять переопределение:
el.style.removeProperty('--space-2');Здесь смыкается с серией про JavaScript: тёмная тема, «настроил пользователь — перекрасилось всё», счётчики, прогресс-бары — это одна запись в CSSOM, а не генерация CSS-строк.
Часть II. CSS Grid: двумерная раскладка
6. Треки, области и fr
Grid описывает сетку по осям сразу. fr — доля свободного пространства; minmax() — диапазон трека.
.layout {
display: grid;
grid-template-columns: 220px 1fr 260px; /* сайдбар | контент | виджет */
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header header header"
"nav main aside"
"footer footer footer";
gap: 16px;
min-height: 100dvh; /* dvh вместо vh — с учётом адресной строки */
}
.layout > .header { grid-area: header; }
.layout > .nav { grid-area: nav; }
.layout > .main { grid-area: main; }
.layout > .aside { grid-area: aside; }
.layout > .footer { grid-area: footer; }auto 1fr auto — строки-«шапка/подвал» займут своё содержимое, а центральная 1fr — всё оставшееся. Это то, за что раньше держали калькуляторы height: 100% - ....
7. Адаптивная сетка без единого медиазапроса
Ключевой приём — auto-fill/auto-fit в паре с minmax(): колонки добавляются сами, как только влезает ещё одна шириной «хотя бы minmax».
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}Разница auto-fill и auto-fit: auto-fit схлопывает пустые колонки в 0 (содержимое тянется на всю ширину), auto-fill оставляет пустые треки-«места» (карточки не растягиваются на весь экран на широких вьюпортах).
8. Выравнивание: по осям и «в одной ячейке»
.parent {
display: grid;
/* положение самих треков внутри контейнера: */
justify-content: center; /* по горизонтальной оси */
align-content: center; /* по вертикальной */
/* выравнивание содержимого ячеек по всему сеточному блоку: */
justify-items: stretch; /* по inline-оси внутри ячейки */
align-items: center; /* по block-оси внутри ячейки */
/* переопределение для одной ячейки — у ребёнка: */
}
.parent > .hero { justify-self: center; align-self: end; }
/* place-* — сокращение align/justify: */
.parent { place-items: center; } /* = align-items: center; justify-items: center */9. Размещение по номерам линий и span
.item-wide {
grid-column: 1 / span 2; /* занять 2 колонки, начиная с линии 1 */
grid-row: 2 / 3; /* от линии 2 до линии 3 */
}
.item-last { grid-column: -2 / -1; } /* отсчёт от конца сетки */Это то, что выносит в CSS «grid-калькуляторы» из Bootstrap: смешение ширины элементов в одном потоке задаётся числом колонок, а не классами col-md-6.
Часть III. Flexbox: одномерное распределение
10. Осевая модель и flex: 1 1 0
Flex распределяет содержимое вдоль одной оси. flex-grow, flex-shrink, flex-basis сворачиваются в shorthand:
.toolbar {
display: flex;
gap: 8px; /* отступы между флексами — без :not(:last-child) */
align-items: center; /* cross-ось (здесь вертикаль) */
justify-content: space-between; /* main-ось (горизонталь) */
}
.toolbar .search { flex: 1 1 0; min-width: 0; } /* растягивается; min-width:0 — чтобы текст обрезался, а не распирал */Шпаргалка по осям: если flex-direction: row (по умолчанию), то main-ось = горизонталь (управляет justify-content), cross = вертикаль (align-items/align-self). Поменяли на column — смысл «поменялся местами», это главная причина «почему выравнивание не работает».
11. «Прижать вправо» без float и абсолютного позиционирования
.nav { display: flex; align-items: center; }
.nav .brand { }
.nav .spacer { flex-grow: 1; } /* или: */
.nav .account { margin-inline-start: auto; } /* «весь остаток слева» — без лишнего элемента */margin-inline-start: auto — канонический способ отодвинуть элемент к концу оси одним объявлением (логическая версия margin-left: auto, работает и в row-reverse, и в RTL).
12. Когда Grid, а когда Flex
| Задача | Инструмент |
|---|---|
| Строка/колонка кнопок, тулбар, «иконка + текст» | Flex |
| Страница: шапка + 2 колонки + подвал (и строки, и колонки) | Grid |
| Галерея одинаковых карточек, адаптирующая число колонок | Grid (auto-fill) |
Вертикальный список «разбросать по высоте» (space-between) | Flex (column) |
| Элемент должен вписаться в чужую ширину и обрезать текст | Flex + min-width: 0 |
Правило: одномерный поток — Flex, двумерная сетка — Grid. Они не конкурируют, а комбинируются: Grid задаёт структуру страницы, а внутри ячеек — Flex.
Часть IV. Каскадные слои: приоритет групп стилей без !important
13. Что такое слой и как он решает «кто сильнее»
История CSS — это война специфичности и !important, которую усиливает порядок объявления. @layer выносит решение над специфичностью: стили группируются в слои, и между слоями приоритет задаётся только порядком слоёв, а специфичность важна лишь внутри одного слоя.
/* 1. объявляем порядок (важно!): */
@layer reset, base, components, utilities;
@layer reset {
*, *::before, *::after { box-sizing: border-box; margin: 0; }
}
@layer base {
body { font: 16px/1.5 system-ui; color: var(--text); }
}
@layer components {
.btn { padding: .5rem 1rem; border-radius: 8px; background: var(--accent); color: #fff; }
}
@layer utilities {
.text-red { color: red; } /* победит .btn из components — потому что слой utilities позже, хотя специфичность 0-1-0 ниже/равна */
.hidden { display: none; }
}Результат: .btn.text-red { } (без !important) красит кнопку — утилита из более позднего слоя перебивает компонент. Это ровно то, ради чего Tailwind генерировал тонны !important.
14. Тонкость порядка и «объявления вне слоёв»
- Слои, не упомянутые в стартовой директиве
@layer a, b, c, добавляются в конец в порядке своего первого объявления. Поэтому список порядка в начале файла — часть контракта. - Правила вне любых слоёв (в обычных, не-important объявлениях) имеют приоритет выше любых слоёв — не-слоеный CSS перекроет и
reset, иutilities. Это осознанный «escape hatch», но держите это в голове. - Инверсия для
!important: внутри important-объявлений приоритет слоёв обратный — important из более раннего слоя бьёт important из более позднего. (Важно не путать: обычные правила — поздний слой сильнее; important — ранний слой сильнее.)
15. Слои из отдельных файлов и @import
<style>
@layer reset, base, components, utilities;
</style>
<link rel="stylesheet" href="reset.css"> <!-- reset.css внутри содержит @layer reset {...} -->/* main.css */
@import url("typography.css") layer(base); /* внешний файл — в заданный слой */
@import url("themes.css") layer(theme);Практический выигрыш для «фронтенда без фреймворка»: у вас появляется честный порядок «сброс → базовые стили → компоненты → утилиты» как декларация намерений, а не как соглашение команды «старайся писать утилиты в конце файла».
Часть V. Нативная вложенность: препроцессор переехал в браузер
16. Базовый синтаксис и &
Вложенные селекторы читаются как иерарчия DOM и убирают «опечатки» повторяющихся префиксов:
.card {
border: 1px solid var(--line);
}
.card:hover { } /* раньше ради этого и держали Sass */
/* теперь: */
.card {
border: 1px solid var(--line);
& .title { font-weight: 600; } /* вложенный потомок */
&:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); }
&:focus-visible { outline: 2px solid var(--accent); }
.actions { /* можно без & — это «& .actions» */
display: flex;
gap: 8px;
}
}& означает «текущий элемент». Он обязателен, когда вложенный селектор начинается не с пробела-комбинатора, а с псевдокласса/комбинатора: &:hover, & > .row, & + .sibling.
17. Чем это НЕ является Sass: правило :is()
Критично для мигрирующих с препроцессоров. Вложенный селектор вида
.wrapper {
.a, .b { color: red; }
}раскрывается не в два независимых правила, а примерно в
.wrapper :is(.a, .b) { color: red; }А :is() повышает приоритет всего составного селектора до приоритета самого специфичного своего аргумента. Следствие: .wrapper .a и .wrapper :is(.a, #b) — это уже не одинаковая специфичность, и с длинными «цепочками через иды» вы можете получить неожиданный приоритет. В Sass вложенность была «текстовым» раскрытием, здесь — семантическим через :is().
18. Вложенные медиазапросы и другие at-rules
Вложенность работает и для условных правил — медиаусловие «применяется» внутри блока:
.card {
display: grid;
@media (min-width: 640px) {
grid-template-columns: 1fr 2fr;
}
@supports (container-type: inline-size) {
/* прогрессивное улучшение */
}
}@nest в браузерном CSS не нужен и не поддерживается — это не прижившийся экспериментальный синтаксис, в реальном коде им не пользуются.
19. :has() — «родительский селектор», которого не хватало
Нативная вложенность и :has() — пара, которая закрывает большую часть «а вот бы по классу ребёнка перекрасить родителя». :has() — это не вложенность, а логический селектор «у предка есть соответствующий потомок/сосед»:
/* карточка подсвечивается, когда внутри неё focus: */
.card:has(:focus-visible) { border-color: var(--accent); }
/* форма невалидна, если внутри есть :invalid: */
form:has(:invalid) .submit { opacity: .5; }
/* строка таблицы темнеет при hover любой ячейки (вместо JS-подсветки) */
tr:has(td:hover) { background: var(--row-hover); }В связке с вложенностью это «реактивный» CSS без JS: состояние родителя выводится из содержимого, а не из класса, который навесил скрипт.
Часть VI. Платформа, догнавшая утилиты и препроцессоры
20. Адаптивная типографика через clamp() без медиазапросов
h1 {
/* минимум 1.5rem, предпочтительно между vw и rem, максимум 3rem */
font-size: clamp(1.5rem, 4vw + 0.5rem, 3rem);
line-height: 1.1;
text-wrap: balance; /* заголовки без «висячих» слов-огрызков */
}Одна строка заменяет три медиазапроса на «разные размеры на мобилке/планшете/десктопе». Это же — про отступы: padding-inline: clamp(1rem, 5vw, 4rem).
21. Логические свойства: отступы, не привязанные к «влево/вправо»
.section {
padding-block: 2rem; /* сверху/снизу, в потоке */
padding-inline: 1.5rem; /* по началу/концу строки (left/right в LTR, right/left в RTL) */
margin-inline: auto; /* центрирование без width+margin-left:auto+margin-right:auto */
border-inline-start: 4px solid var(--accent);
}Пишете inline/start/end/block — и разметка становится двунаправленной (RTL) «бесплатно», без дублирующих правил.
22. Контейнерные запросы: адаптив по блоку, а не по экрану
Компонент должен перестраиваться от своей ширины, а не от вьюпорта (карточка и в сайдбаре, и на всю ширину — разная).
.cards { container-type: inline-size; container-name: cards; }
@container cards (min-width: 500px) {
.card { grid-template-columns: 120px 1fr; }
}/* единицы от контейнера: 1cqi = 1% от inline-ширины ближайшего query-контейнера */
.card h3 { font-size: clamp(1rem, 4cqi, 1.5rem); }Это то, чего не давали медиазапросы в компонентном CSS без JS-обёрток.
23. «Почти-утилиты» прямо из платформы
Несколько правил, которые закрывают типичные классы из Tailwind, но как нормальные CSS-фичи:
.flow > * + * { margin-block-start: 1rem; } /* вертикальный ритм без utility-класса mt */
.center { display: grid; place-content: center; } /* «центрируй по обеим осям» */
.truncate { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
.full-bleed { width: 100vw; margin-inline: calc(50% - 50vw); } /* разорвать контейнер */
.stack { display: flex; flex-direction: column; gap: var(--space-2); }24. Тёмная тема и системные подсказки: color-scheme, prefers-*
:root { color-scheme: light dark; } /* браузер сам красит scrollbar/form-controls */
@media (prefers-color-scheme: dark) {
:root { --bg: #0b0f14; --text: #e5e7eb; --line: #1f2937; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}prefers-reduced-motion — доступность: использование системной настройки «убрать анимацию». accent-color в связке с color-scheme красит чекбоксы/слайдеры без стилей на каждый input.
Часть VII. Практика: компонент «карточка» без фреймворка
Собираем всё вместе: переменные + слои + вложенность + grid + clamp + :has() — минимальный переиспользуемый компонент, у которого состояние и тема живут в CSS, а не в классах, которые навесит JS.
/* порядок каскада — один раз на проект */
@layer reset, base, components, utilities;
@layer base {
:root {
--accent: #4f46e5;
--text: #111827;
--muted: #6b7280;
--line: #e5e7eb;
--radius: 12px;
--space-2: .5rem;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root { --text: #e5e7eb; --muted: #9ca3af; --line: #1f2937; color-scheme: dark; }
}
}
@layer components {
.cards { container-type: inline-size; } /* блок-«контейнер» для @container ниже */
/* HTML: <article class="card"><img class="thumb"><div class="body">…</div></article> */
.card {
--card-pad: 1.25rem; /* локальная переменная компонента */
display: grid;
grid-template-columns: 1fr; /* мобильно: одна колонка, превью сверху */
gap: var(--space-2);
padding: var(--card-pad);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--text);
/* вложенность + состояние родителя через :has() */
&:has(.thumb:hover) { border-color: var(--accent); }
.thumb {
aspect-ratio: 16 / 9; /* без фиксированной высоты и padding-bottom-хака */
object-fit: cover;
border-radius: calc(var(--radius) - 4px);
background: var(--line);
}
.body { display: grid; gap: var(--space-2); align-content: start; } /* вложенная одномерная сетка содержимого */
.title { font-size: clamp(1.05rem, 2.5cqi, 1.35rem); text-wrap: balance; }
.meta { color: var(--muted); font-variant-numeric: tabular-nums; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
/* прижимы без margin-left:auto в жёсткой логике */
.actions { display: flex; justify-content: flex-end; gap: 8px; }
/* адаптив не по вьюпорту, а по контейнеру: две колонки, когда блок широкий */
@container (min-width: 480px) {
grid-template-columns: 160px 1fr;
align-items: start;
}
}
}
@layer utilities {
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
}Что здесь «стандартного» и почему без библиотек:
- Палитра и радиусы — в
:root/--*: темизация = 5 строк переопределения, не второй CSS-файл (см. Часть I). - Слои: компонент не воюет с утилитами через
!important— приоритет задаёт@layer reset, base, components, utilities(Часть IV). - Вложенность +
:has(): «карточка подсвечивается при hover превью» — CSS-состояние, без обработчика и без навешивания классов (Часть V). aspect-ratioиclamp()/cqi: пропорции и типографика адаптивны без медиазапросов и JS-ресайзеров (Часть VI).@container: один и тот же.cardкорректно и в сайдбаре, и в основной колонке — потому что реагирует на ширину блока, а не экрана (Часть VI).
Чек-лист: готовность и грабли
calc()и пробелы: вокруг+/-внутриcalc()пробел обязателен (calc(100% - 20px), а неcalc(100%-20px)); нельзя перемножать две единицы длины.var()без фолбэка → свойство схлопнется в невалидное (не в «0», а в «нет значения») и унаследует/сбросит. Всегда думайте, нужен ли второй аргументvar(--x, fallback).- Кастомное свойство не анимируется числом/цветом без
@property— иначе получите скачок вместо плавного перехода. - Grid vs Flex: двумерная структура — Grid; распределение вдоль одной оси — Flex; внутри Grid-ячейки почти всегда Flex.
min-width: 0на гибком элементе, иначе длинный непереносимый текст не даст сжаться и «раздвинет» вёрстку.@layer: объявляйте список порядка слоёв один раз в начале; не-слоеные правила бьют любые слои, а среди!importantприоритет слоёв обратный.- Вложенность ≠ Sass: составной вложенный селектор оборачивается в
:is()и получает специфичность сильнейшей части — следите за приоритетом, когда мигрируете. &обязателен перед псевдоклассом/комбинатором (&:hover,& > x); без него:hover { }внутри блока — это «ховер над потомком».@nestне поддерживается — не используйте, это не тот синтаксис, что в черновиках.:has(),@container, нативная вложенность — сверяйте версии внедрения на caniuse, если нужна старая мобильная аудитория; для прогрессивного улучшения держите базовый вариант в@supports/дефолтных правилах.
Материал носит образовательно-практический характер. Синтаксис и семантика соответствуют актуальному CSS-стандарту (CSS Nesting, CSS Conditional Rules,
@layer,@property,@container,:has()) и поведению Chromium/Firefox/Safari на октябрь 2026; по версиям внедрения конкретных фич сверяйте MDN и caniuse для вашей аудитории браузеров.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
