Современный CSS: кастомные свойства, grid/flex, каскадные слои и вложенность

Переменные, двумерная сетка, настоящие каскадные слои и нативная вложенность покрывают большую часть того, ради чего раньше ставили Sass или Tailwind. Эта статья — про то, что реально доступно в браузере прямо сейчас, без сборки и без библиотек: как устроены кастомные свойства, когда Grid, а когда Flex, как слои снимают «войну специфичности» и как нативная вложенность заменяет препроцессор.

Дано по состоянию на октябрь 2026. Все разобранное поддерживается актуальными версиями Chromium, Firefox и Safari.


Часть I. Кастомные свойства: переменные, которые живут в DOM

1. Синтаксис и главное отличие от препроцессоров

Кастомное свойство — это имя с двумя дефисами в начале. Задаётся в любом блоке правил, читается функцией var():

CSS
: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. Наследование и каскад решают, какое значение «дошло» до элемента

CSS
section { --gap: 16px; }                 /* у всех детей --gap = 16px */
.card { gap: var(--gap); }               /* возьмёт 16px от ближайшего предка */
.card.wide { --gap: 32px; }              /* но сам переопределит для себя и детей */

Значение свойства всегда «то, что вычислено для конкретного элемента» с учётом наследования и специфичности. Это же позволяет паттерн «настроил родителя — перестроились дети»: смените --fs на article — и весь вложенный текст пересчитается через calc().

3. Арифметика: calc(), вложенные var() и масштабирование

CSS
: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 (это же даёт валидацию значения и значение по умолчанию):

CSS
@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

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() — диапазон трека.

CSS
.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».

CSS
.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 1rem;
}

Разница auto-fill и auto-fit: auto-fit схлопывает пустые колонки в 0 (содержимое тянется на всю ширину), auto-fill оставляет пустые треки-«места» (карточки не растягиваются на весь экран на широких вьюпортах).

8. Выравнивание: по осям и «в одной ячейке»

CSS
.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

CSS
.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:

CSS
.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 и абсолютного позиционирования

CSS
.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 выносит решение над специфичностью: стили группируются в слои, и между слоями приоритет задаётся только порядком слоёв, а специфичность важна лишь внутри одного слоя.

CSS
/* 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

HTML
<style>
  @layer reset, base, components, utilities;
</style>
<link rel="stylesheet" href="reset.css">   <!-- reset.css внутри содержит @layer reset {...} -->
CSS
/* main.css */
@import url("typography.css") layer(base);       /* внешний файл — в заданный слой */
@import url("themes.css") layer(theme);

Практический выигрыш для «фронтенда без фреймворка»: у вас появляется честный порядок «сброс → базовые стили → компоненты → утилиты» как декларация намерений, а не как соглашение команды «старайся писать утилиты в конце файла».


Часть V. Нативная вложенность: препроцессор переехал в браузер

16. Базовый синтаксис и &

Вложенные селекторы читаются как иерарчия DOM и убирают «опечатки» повторяющихся префиксов:

CSS
.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()

Критично для мигрирующих с препроцессоров. Вложенный селектор вида

CSS
.wrapper {
  .a, .b { color: red; }
}

раскрывается не в два независимых правила, а примерно в

CSS
.wrapper :is(.a, .b) { color: red; }

А :is() повышает приоритет всего составного селектора до приоритета самого специфичного своего аргумента. Следствие: .wrapper .a и .wrapper :is(.a, #b) — это уже не одинаковая специфичность, и с длинными «цепочками через иды» вы можете получить неожиданный приоритет. В Sass вложенность была «текстовым» раскрытием, здесь — семантическим через :is().

18. Вложенные медиазапросы и другие at-rules

Вложенность работает и для условных правил — медиаусловие «применяется» внутри блока:

CSS
.card {
  display: grid;

  @media (min-width: 640px) {
    grid-template-columns: 1fr 2fr;
  }

  @supports (container-type: inline-size) {
    /* прогрессивное улучшение */
  }
}

@nest в браузерном CSS не нужен и не поддерживается — это не прижившийся экспериментальный синтаксис, в реальном коде им не пользуются.

19. :has() — «родительский селектор», которого не хватало

Нативная вложенность и :has() — пара, которая закрывает большую часть «а вот бы по классу ребёнка перекрасить родителя». :has() — это не вложенность, а логический селектор «у предка есть соответствующий потомок/сосед»:

CSS
/* карточка подсвечивается, когда внутри неё 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() без медиазапросов

CSS
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. Логические свойства: отступы, не привязанные к «влево/вправо»

CSS
.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. Контейнерные запросы: адаптив по блоку, а не по экрану

Компонент должен перестраиваться от своей ширины, а не от вьюпорта (карточка и в сайдбаре, и на всю ширину — разная).

CSS
.cards { container-type: inline-size; container-name: cards; }

@container cards (min-width: 500px) {
  .card { grid-template-columns: 120px 1fr; }
}
CSS
/* единицы от контейнера: 1cqi = 1% от inline-ширины ближайшего query-контейнера */
.card h3 { font-size: clamp(1rem, 4cqi, 1.5rem); }

Это то, чего не давали медиазапросы в компонентном CSS без JS-обёрток.

23. «Почти-утилиты» прямо из платформы

Несколько правил, которые закрывают типичные классы из Tailwind, но как нормальные CSS-фичи:

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-*

CSS
: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.

CSS
/* порядок каскада — один раз на проект */
@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 для вашей аудитории браузеров.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.