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