Перейти к содержимому

CSS Modules, PostCSS и современный CSS: изоляция, слои и контейнеры

Tailwind закрывает большинство задач нового проекта, но фронтендер, который умеет только Tailwind, как автомеханик, умеющий ездить только на одной марке. Legacy-код на CSS Modules, сторонние виджеты со своим CSS, email-вёрстка, библиотечные компоненты — всё это живёт за пределами утилитарного мира. И главное: под капотом Tailwind лежат те же механизмы — слои каскада, custom properties, container queries. Эта глава про них.

Продакшен-контекст: в проекте, которому пять лет, ты гарантированно встретишь .module.css рядом с «голым» CSS и PostCSS-конфигом с десятком плагинов. Понимание того, как CSS Modules изолируют классы и что делает пайплайн с твоим кодом до браузера, — это разница между «всё сломалось после обновления сборки» и «вижу, на каком этапе что-то пошло не так».

CSS Modules — не фреймворк, а соглашение сборщика: любой класс в файле *.module.css автоматически получает уникальное имя (хеш из пути файла и имени класса). Импортируешь файл как объект — ключи доступны только этому модулю.

Card.module.css
.card {
padding: 1rem;
border-radius: 0.75rem;
background: var(--surface);
transition: box-shadow 150ms ease;
}
.card:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 0.08);
}
.title {
composes: heading from './typography.module.css'; /* композиция чужих стилей */
margin-bottom: 0.5rem;
}
/* сброс для дочерних элементов из markdown/стороннего виджета */
.content :global(.prose pre) {
background: #0d1117;
border-radius: 6px;
}
import styles from './Card.module.css';
export function Card() {
return (
<article className={styles.card}>
<h2 className={styles.title}>Заголовок</h2>
<div className={styles.content}>...</div>
</article>
);
}

После сборки styles.card превратится в что-то вроде Card_card__a3f9b — класс глобально уникален, коллизий нет, специфичность остаётся (0-1-0). Это тот же трюк, что и в BEM (один класс на сущность), но автоматический: не нужно дисциплинировать именование, изоляция гарантирована инструментом.

composes подключает класс из того же или другого модуля: в итоговом HTML элемент получает оба класса, CSS не дублируется. Это единственный «механизм наследования» в CSS Modules, и он работает без повышения специфичности. Важно: composes должен быть первым свойством в правиле, наследоваться можно только классы (не теги, не #id), и циклы запрещены — сборщик упадёт с понятной ошибкой.

:global(.class) выключает хеширование для конкретного селектора. Типичные случаи: стилизация дочерних элементов, приходящих из markdown или стороннего виджета; глобальные утилиты-исключения. Злоупотребление :global возвращает тебя к войнам специфичности — используй точечно.

PostCSS — это парсер и трансформатор CSS: плагины обрабатывают AST (абстрактное синтаксическое дерево) стилей. В современном стеке (Next.js, Vite) твой CSS почти наверняка проходит через него, даже если ты этого не замечаешь.

postcss.config.js
module.exports = {
plugins: {
'postcss-import': {}, // склейка @import в один файл
tailwindcss: {}, // Tailwind — тоже PostCSS-плагин
'postcss-preset-env': { // полифиллы по browserslist
stage: 2, // черновики стандартов (включая nesting)
},
autoprefixer: {}, // вендорные префиксы
cssnano: { preset: 'default' }, // минификация для prod
},
};

Что даёт каждый кирпич:

  • autoprefixer — добавляет -webkit-, -moz- по данным browserslist (репозиторий). В 2026-м нужен редко (flexbox/grid стабильны), но для mask, backdrop-filter и пропусков в старых Safari — обязателен. Пиши чистый CSS, префиксы — забота пайплайна.
  • postcss-preset-env + nesting — вложенность & как в Sass транспилируется в плоские селекторы. Но помни правило из главы про Tailwind: глубокая вложенность = сложные селекторы = дороже матчинг. Держи глубину ≤ 2–3. Список транспилируемых фич по stage — preset-env.cssdb.org.
  • postcss-import@import внутри CSS — это блокирующие сетевые запросы в браузере. Плагин склеивает файлы на этапе сборки, и в прод уходит один файл.

Всё это опирается на browserslist — единый источник правды о целевых браузерах:

// package.json — один раз для всего проекта
"browserslist": [
"last 2 versions",
"not dead",
"> 0.2%"
]

Autoprefixer, preset-env, Babel и babel-пресets читают именно эту секцию: сменил цели — изменился выход всех инструментов сразу, без правки их конфигов по отдельности. Сужение списка (например, до last 2 Chrome versions для внутреннего инструмента) — законный способ урезать полифиллы и префиксы, но делай это осознанно: браузеры пользователей, а не твои, решают.

Современный CSS: то, что уже можно использовать

Заголовок раздела «Современный CSS: то, что уже можно использовать»

Поддержка браузерами на конец 2025-го: всё ниже — baseline (работает везде без транспиляции, кроме оговорок).

.card {
/* значение с fallback — третий аргумент */
background: var(--surface, #ffffff);
border-color: var(--border, #e2e8f0);
}
/* перекрытие на уровне модификатора — без новых классов-комбинаций */
.card--danger {
--surface: #fef2f2;
--border: #fecaca;
}
/* чтение из атрибута и inline-стиля — мост JS ↔ CSS */
.progress { width: calc(var(--value, 0) * 1%); }
// React: динамическое значение без пересборки стилей
<div className="progress" style={{ '--value': uploadPercent } as React.CSSProperties} />

Ключевое отличие от переменных препроцессоров: custom properties живут в браузере, их можно менять в рантайме, читать в JS (getComputedStyle), а с @property — анимировать с правильной интерполяцией (например, ширина прогресс-бара от 0% до 80% плавно, а не скачком):

@property --progress {
syntax: '<percentage>'; /* браузер знает тип — умеет интерполировать */
inherits: false;
initial-value: 0%;
}
.progress-bar {
width: var(--progress);
transition: --progress 600ms ease; /* работает только с @property */
}
// JS-сторона без библиотек анимации
element.style.setProperty('--progress', '80%');

:has() — селектор «родитель по состоянию ребёнка»

Заголовок раздела «:has() — селектор «родитель по состоянию ребёнка»»

Двадцать лет CSS не умел отвечать на вопрос «есть ли у меня ребёнок с таким-то?». Теперь умеет:

/* форма подсвечивает рамку, если внутри есть невалидное поле */
form:has(:invalid) {
outline: 2px solid rgb(239 68 68 / 0.4);
}
/* карточка перестраивается, если внутри картинка */
.card:has(img) {
grid-template-rows: auto 1fr;
}
/* label реагирует на состояние чекбокса внутри — без JS */
label:has(input:checked) {
border-color: #4f46e5;
background: #eef2ff;
}

Осторожно с производительностью: :has() заставляет браузер следить за поддеревом. На небольших селекторах это бесплатно, на body:has(...), меняющемся каждый кадр, — нет.

Медиа-запросы смотрят на viewport. Но карточка в сайдбаре и та же карточка на всю ширину должны вести себя по-разному — и это зависит от ширины контейнера, а не окна. Container Queries решают именно это:

.card-wrapper {
container-type: inline-size; /* элемент становится «измеряемым контейнером» */
container-name: card;
}
/* когда контейнер .card-wrapper шире 400px */
@container card (min-width: 400px) {
.card { display: grid; grid-template-columns: 120px 1fr; }
.card__title { font-size: 1.25rem; }
}
/* без имени — ближайший подходящий контейнер */
@container (max-width: 240px) {
.card__meta { display: none; }
}
<!-- одна и та же разметка, три контекста — и все корректны -->
<aside class="sidebar">
<div class="card-wrapper"><article class="card">…</article></div> <!-- узкая, вертикальная -->
</aside>
<main>
<div class="card-wrapper"><article class="card">…</article></div> <!-- широкая, горизонтальная -->
</main>

В Tailwind то же самое через плагин @tailwindcss/container-queries: @container + префиксы @md:flex-row. Ограничения: контейнер не может измерять себя же (нужен промежуточный wrapper, как в примере), и container-type: inline-size делает элемент блочным по сути — поплывшая вёрстка после его добавления — известная грабля.

Grid-колонки вложенной карточки часто «не встают» в общий ритм. subgrid позволяет вложенной сетке занять дорожки родителя:

.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.card {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid; /* заголовок/тело/футер встают в общие дорожки */
}

Поддержка: все современные браузеры с 2024 года. Классический кейс — карточки товаров с кнопкой, выровненной по общей нижней линии независимо от длины описания. До subgrid эту задачу решали хаками: фиксированной высотой, JS-выравниванием или flex-колонками с растягиванием — все три варианта хуже по гибкости и перфомансу.

До слоёв побеждал «кто позже в файле/документе и тяжелее по специфичности». Слои переворачивают правило: сначала сравниваются слои, специфичность внутри слоя:

@layer reset, vendor, components, utilities;
@layer utilities { .hidden { display: none; } } /* победит в любом случае */
@layer components { .modal { display: block; } } /* проиграет слою выше */

Это тот же механизм, которым Tailwind гарантирует, что утилиты бьют компонентные классы. Практическая польза вне Tailwind: стили сторонней библиотеки кладёшь в @layer vendor — и свои компоненты побеждают их без !important и специфичностных хаков. Неупорядоченные слои (объявленные в первом вхождении) идут в конец — веди их список явно в корневом CSS. Справка по синтаксису — MDN: @layer.

  1. Селектор тега в .module.css «для удобства». h2 { ... } в модуле — глобальный селектор (модули хешируют только классы). Стили утечут на всё приложение. Хорошо: только классы, теги — только через .module h2 (специфичность вырастет — ещё одна причина не делать так).
  2. Динамические имена классов модуля. styles[card-${size}] — после хеширования ключи в объекте остаются исходными, но с опечаткой получишь undefined и молча сломанный стиль. Хорошо: явная карта const sizes = { sm: styles.cardSm, lg: styles.cardLg } — TypeScript поймает опечатку.
  3. container-type: inline-size «схлопывает» высоту. После добавления контейнера элемент перестаёт учитывать размеры содержимого по горизонтали — фон и бордеры ведут себя странно. Лечение: измеряй wrapper, стилизуй внутренний элемент (как в примере выше).
  4. @import вместо postcss-import. Разработка «работает» (Vite подхватывает), а прод в браузере делает N блокирующих запросов. Проверяй: в собранном CSS не должно быть ни одного @import.
  5. Глубокий nesting ради «организации». Пять уровней вложенности — это селектор с весом (0-5-0) и сложный матчинг. Читабельность падает, переопределять дорого. Правило: 2 уровня максимум, вложенность — для состояний (&:hover, &.active), не для структуры DOM.
  6. !important против сторонней библиотеки. Сначала попробуй @layer — объяви слои, положи чужой CSS в ранний слой, свои стили победят без взлома каскада. !important — последний резерв.
  1. Как CSS Modules обеспечивают изоляцию? Сборщик хеширует имена классов (путь + имя → уникальное имя), импорт даёт объект с ключами. Коллизии невозможны, специфичность остаётся (0-1-0), как в BEM, но без дисциплины именования.
  2. Что делает composes и чем отличается от наследования в CSS? Подключает другой класс к элементу (в HTML будет два класса), без дублирования CSS и без повышения специфичности. Работает только с классами, циклы запрещены.
  3. Зачем нужен PostCSS, если есть современные браузеры? Склейка @import, полифиллы по browserslist, nesting-транспиляция для старых целей, автопрефиксы, минификация. Даже «baseline» CSS проходит через пайплайн для оптимизации размера.
  4. Чем Container Queries отличаются от Media Queries и когда они нужны? Media — про viewport, Container — про размер конкретного контейнера. Нужны для компонентов, размещаемых в разных контекстах (карточка в сайдбаре и на всю ширину), когда адаптив компонента не должен зависеть от страницы.
  5. Что такое @layer и какую проблему решает? Явный порядок каскада: слой, объявленный позже, побеждает независимо от специфичности. Позволяет побеждать стили сторонних библиотек без !important и специфичностных гонок.
  6. Как анимировать custom property? Напрямую — нельзя (значение переключается скачком). С @property — регистрируешь свойство с syntax и inherits, после чего браузер интерполирует его в transitions/animations.
  7. Зачем :has(), если есть JS? Декларативный отклик на состояние DOM без скриптов и без перерендера: форма, подсвечивающая ошибки, label, реагирующий на чекбокс, раскладка по наличию контента. Меньше JS — меньше INP-рисков.
  1. Порт карточки на CSS Modules. Перепиши один компонент с Tailwind на .module.css: карточка, заголовок, мета-информация. Критерий: без :global (кроме случая стилизации вложенного markdown), composes для типографики, визуальная идентичность.
  2. Построение PostCSS-пайплайна. На Vite-проекте собери конфиг: import → preset-env (stage 2) → autoprefixer → cssnano. Критерий: в прод-сборке ноль @import, browserslist поддерживает только последние 2 версии — сравни вывод с autoprefixer’ом on/off.
  3. Компонент на Container Queries. Карточка с аватаром, которая в контейнере < 300px — вертикальная, шире — горизонтальная (аватар слева). Критерий: один экземпляр в сайдбаре 260px и один в основной колонке 720px выглядят корректно без единого медиа-запроса.
  4. Слои против !important. Подключи сторонний CSS (например, стили markdown-рендерера) и переопредели цвет ссылки двумя способами: через @layer vendor и через !important. Критерий: слоевой вариант работает, и ты можешь объяснить, почему это устойчивее.
  5. :has() без JS. Сделай список задач, где карточка с хотя бы одним выполненным пунктом получает зелёную обводку — только CSS. Критерий: переключение чекбокса меняет обводку мгновенно, без React-состояния.